diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml new file mode 100644 index 0000000000..ce75b7a57c --- /dev/null +++ b/.github/actions/prepare-build/action.yml @@ -0,0 +1,47 @@ +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: 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..e0d32bd596 --- /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: Upload file with specific name + 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: Add hash to release notes + 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/actions/swap/action.yml b/.github/actions/swap/action.yml new file mode 100644 index 0000000000..87d670b147 --- /dev/null +++ b/.github/actions/swap/action.yml @@ -0,0 +1,44 @@ +name: 'Set Swap Space' +description: 'Add moar swap' +branding: + icon: 'crop' + color: 'orange' +inputs: + swap-size-gb: + description: 'Swap space to create, in Gigabytes.' + required: false + default: '10' +runs: + using: "composite" + steps: + - name: Swap space report before modification + shell: bash + run: | + echo "Memory and swap:" + free -h + echo + swapon --show + echo + - name: Set Swap + shell: bash + run: | + export SWAP_FILE=$(swapon --show=NAME | tail -n 1) + echo "Swap file: $SWAP_FILE" + if [ -z "$SWAP_FILE" ]; then + SWAP_FILE=/opt/swapfile + else + sudo swapoff $SWAP_FILE + sudo rm $SWAP_FILE + fi + sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE + sudo chmod 600 $SWAP_FILE + sudo mkswap $SWAP_FILE + sudo swapon $SWAP_FILE + - name: Swap space report after modification + shell: bash + run: | + echo "Memory and swap:" + free -h + echo + swapon --show + echo diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2338258d82..ca1bc79510 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@v5 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@v2 with: body: ${{ steps.build_changelog.outputs.changelog }} prerelease: true @@ -52,183 +94,295 @@ 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: 22.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 - 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 + should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} + - 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 - + should_run: true + - os: 24.04 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-ubuntu-24_04-x86-64 + desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb + should_run: true 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 + if: matrix.should_run == true uses: actions/checkout@v3 - - name: Setup Haskell - uses: haskell-actions/setup@v2 + - name: Setup swap + if: matrix.ghc == '8.10.7' && matrix.should_run == true + uses: ./.github/actions/swap with: - ghc-version: ${{ matrix.ghc }} - cabal-version: "3.10.1.0" + swap-size-gb: 30 + + # Otherwise we run out of disk space with Docker build + - name: Free disk space + if: matrix.should_run == true + shell: bash + run: ./scripts/ci/linux_util_free_space.sh - name: Restore cached build - id: restore_cache - uses: actions/cache/restore@v3 + if: matrix.should_run == true + 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 + if: matrix.should_run == true + 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 + if: matrix.should_run == true + 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 + if: matrix.should_run == true 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 + if: matrix.should_run == true 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 + if: matrix.should_run == true + 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 + if: matrix.should_run == true 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.should_run == true 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-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Upload CLI + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true + 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.should_run == true + 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) - 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' + - name: Prepare Desktop + id: linux_desktop_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: bash run: | - scripts/desktop/make-appimage-linux.sh - path=$(echo $PWD/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 + path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb ) + echo "package_path=$path" >> $GITHUB_OUTPUT + echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Mac build desktop + - name: Upload Desktop + uses: ./.github/actions/prepare-release + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true + 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.os == '22.04' && matrix.should_run == true + shell: docker exec -t builder sh -eu {0} + run: | + scripts/desktop/make-appimage-linux.sh + + - name: Prepare AppImage + id: linux_appimage_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true + shell: bash + run: | + path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage) + echo "appimage_path=$path" >> $GITHUB_OUTPUT + echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Upload AppImage + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true + 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 + if: matrix.should_run == true + shell: bash + run: | + sudo chmod -R 777 dist-newstyle ~/.cabal + sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + + - name: Run tests + if: matrix.should_run == true + timeout-minutes: 120 + shell: bash + run: | + i=1 + attempts=1 + ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3 + while [ "$i" -le "$attempts" ]; do + if ./simplex-chat-test; then + break + else + echo "Attempt $i failed, retrying..." + i=$((i + 1)) + sleep 1 + fi + done + if [ "$i" -gt "$attempts" ]; then + echo "All "$attempts" attempts failed." + exit 1 + fi + +# ========================= +# 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-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $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 }} @@ -238,88 +392,77 @@ jobs: scripts/ci/build-desktop-mac.sh path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg) 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 + echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $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' - timeout-minutes: 40 + - name: Run tests + timeout-minutes: 120 shell: bash - run: cabal test --test-show-details=direct + run: | + i=1 + attempts=1 + ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3 + while [ "$i" -le "$attempts" ]; do + if cabal test --test-show-details=direct; then + break + else + echo "Attempt $i failed, retrying..." + i=$((i + 1)) + sleep 1 + fi + done + if [ "$i" -gt "$attempts" ]; then + echo "All "$attempts" attempts failed." + exit 1 + fi - # 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 +474,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 +491,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-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $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 + echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $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 b/Dockerfile index 7b9641777a..cdcbc40d7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local # Compile simplex-chat RUN cabal update -RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' +RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' # Strip the binary from debug symbols to reduce size RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \ 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/PRIVACY.md b/PRIVACY.md index 7c4bfbf660..18e5539726 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -123,6 +123,16 @@ This section applies only to the experimental group directory operated by Simple [SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). +#### Public groups and content channels + +You may participate in a public group and receive content from a public channel (Group). In case you send messages or comments to the Group, you grant a license: +- to all recipients: + - to share your messages with the new Group members and outside of the group, e.g. via quoting (replying), forwarding and copy-pasting your message. When your message is deleted or marked as deleted, the copies of your message will not be deleted. + - to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message. +- to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages. + +Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators. + #### User Support The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information. @@ -131,9 +141,9 @@ The app includes support contact operated by SimpleX Chat Ltd. If you contact su Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics. -Preset server operators will not provide general access to their servers or the data on their servers to each other. +Preset server operators must not provide general access to their servers or the data on their servers to each other. -Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information on the servers. +Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing illegal content identified in publicly accessible resources (contact and group addresses, and downloadable files). This control port access only allows deleting known links and files, and accessing aggregate server-wide statistics, but does NOT allow enumerating any information on the servers or accessing statistics related to specific users. ### Information Preset Server Operators May Share @@ -148,7 +158,7 @@ The cases when the preset server operators may share the data temporarily stored - To detect, prevent, or otherwise address fraud, security, or technical issues. - To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law. -At the time of updating this document, the preset server operators have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law. +By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law. Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md). @@ -190,7 +200,18 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b **Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves. -**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. +**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software or by using a 3rd party client applications that satisfies the requirements of the Conditions of use (see the next section); 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. + +**3rd party client applications**. You may use a 3rd party application (App) to access preset operators' Infrastructure or systems, provided that this App: +- is compatible with the protocol specifications not older than 1 year, +- provides user-to-user messaging only or enables automated chat bots sending messages requested by users (in case of bots, it must be made clear to the users that these are automated bots), +- implements the same limits, rules and restrictions as Software, +- requires that the users accept the same Conditions of use of preset operators' Infrastructure as in Software prior to providing access to this Infrastructure, +- displays the notice that it is the App for using SimpleX network, +- provides its source code under open-source license accessible to the users via the App interface. In case the App uses the source code of Software, the App's source code must be provided under AGPLv3 license, and in case it is developed without using Software code its source code must be provided under any widely recognized free open-source license, +- does NOT use the branding of SimpleX Chat Ltd without the permission, +- does NOT pretend to be Software, +- complies with these Conditions of use. **Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss. @@ -222,4 +243,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b **Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators. -Updated November 14, 2024 +Updated March 3, 2025 diff --git a/README.md b/README.md index 936667da8c..40d552b84d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ You must: Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. -You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D) +You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D) There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform: @@ -169,6 +169,7 @@ It is possible to donate via: - ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg +- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq - DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf - SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - please ask if you want to donate any other coins. @@ -234,6 +235,10 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md) + +[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md) + [Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) [Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) @@ -305,12 +310,13 @@ What is already implemented: 15. Manual messaging queue rotations to move conversation to another SMP relay. 16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html). 17. Local files encryption. +18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds). We plan to add: 1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days). 2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. -3. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code. +3. Reproducible clients builds – this is a complex problem, but we are aiming to have it in 2025 at least partially. 4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. ## For developers diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index ad8c661e1c..3f6998c9ec 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -54,7 +54,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { try await apiVerifyToken(token: token, nonce: nonce, code: verification) m.tokenStatus = .active } catch { - if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr { + if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr { m.tokenStatus = .expired } logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json new file mode 100644 index 0000000000..cb29f09fe1 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "vertical_logo_x1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "vertical_logo_x2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "vertical_logo_x3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png new file mode 100644 index 0000000000..f916e43ea9 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png differ diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png new file mode 100644 index 0000000000..bb35878f0c Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png differ diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png new file mode 100644 index 0000000000..c55f481b36 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png differ diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 652258415e..2ad8d546f2 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -11,12 +11,10 @@ import SimpleXChat private enum NoticesSheet: Identifiable { case whatsNew(updatedConditions: Bool) - case updatedConditions var id: String { switch self { case .whatsNew: return "whatsNew" - case .updatedConditions: return "updatedConditions" } } } @@ -76,7 +74,7 @@ struct ContentView: View { } } - @ViewBuilder func allViews() -> some View { + func allViews() -> some View { ZStack { let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. @@ -211,7 +209,7 @@ struct ContentView: View { } } - @ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View { + private func activeCallInteractiveArea(_ call: Call) -> some View { HStack { Text(call.contact.displayName).font(.body).foregroundColor(.white) Spacer() @@ -278,18 +276,18 @@ struct ContentView: View { let showWhatsNew = shouldShowWhatsNew() let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false noticesShown = showWhatsNew || showUpdatedConditions - if showWhatsNew { + if showWhatsNew || showUpdatedConditions { noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions) - } else if showUpdatedConditions { - noticesSheetItem = .updatedConditions } } } } prefShowLANotice = true connectViaUrl() + showReRegisterTokenAlert() } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } + .onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() } .sheet(item: $noticesSheetItem) { item in switch item { case let .whatsNew(updatedConditions): @@ -298,13 +296,6 @@ struct ContentView: View { .if(updatedConditions) { v in v.task { await setConditionsNotified_() } } - case .updatedConditions: - UsageConditionsView( - currUserServers: Binding.constant([]), - userServers: Binding.constant([]) - ) - .modifier(ThemedBackground(grouped: true)) - .task { await setConditionsNotified_() } } } if chatModel.setDeliveryReceipts { @@ -315,6 +306,12 @@ struct ContentView: View { .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in + if let url = userActivity.webpageURL { + logger.debug("onContinueUserActivity.NSUserActivityTypeBrowsingWeb: \(url)") + chatModel.appOpenUrl = url + } + } } private func setConditionsNotified_() async { @@ -446,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( @@ -468,6 +465,21 @@ struct ContentView: View { } } + func showReRegisterTokenAlert() { + dismissAllSheets() { + let m = ChatModel.shared + if let errorTknStatus = m.reRegisterTknStatus, let token = chatModel.deviceToken { + chatModel.reRegisterTknStatus = nil + AlertManager.shared.showAlert(Alert( + title: Text("Notifications error"), + message: Text(tokenStatusInfo(errorTknStatus, register: true)), + primaryButton: .default(Text("Register")) { reRegisterToken(token: token) }, + secondaryButton: .cancel() + )) + } + } + } + private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) } diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift new file mode 100644 index 0000000000..6c47d654e0 --- /dev/null +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -0,0 +1,2318 @@ +// +// APITypes.swift +// SimpleX +// +// Created by EP on 01/05/2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SimpleXChat +import SwiftUI + +// some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised +enum ChatCommand: ChatCmdProtocol { + case showActiveUser + case createActiveUser(profile: Profile?, pastTimestamp: Bool) + case listUsers + case apiSetActiveUser(userId: Int64, viewPwd: String?) + case setAllContactReceipts(enable: Bool) + case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) + case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) + case apiHideUser(userId: Int64, viewPwd: String) + case apiUnhideUser(userId: Int64, viewPwd: String) + case apiMuteUser(userId: Int64) + case apiUnmuteUser(userId: Int64) + case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) + case startChat(mainApp: Bool, enableSndFiles: Bool) + case checkChatRunning + case apiStopChat + case apiActivateChat(restoreChat: Bool) + case apiSuspendChat(timeoutMicroseconds: Int) + case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) + case apiSetEncryptLocalFiles(enable: Bool) + case apiExportArchive(config: ArchiveConfig) + case apiImportArchive(config: ArchiveConfig) + case apiDeleteStorage + case apiStorageEncryption(config: DBEncryptionConfig) + case testStorageEncryption(key: String) + case apiSaveSettings(settings: AppSettings) + case apiGetSettings(settings: AppSettings) + case apiGetChatTags(userId: Int64) + case apiGetChats(userId: Int64) + case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) + case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiCreateChatTag(tag: ChatTagData) + case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) + case apiDeleteChatTag(tagId: Int64) + case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData) + case apiReorderChatTags(tagIds: [Int64]) + case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) + case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) + case apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) + case apiDeleteChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) + case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) + case apiArchiveReceivedReports(groupId: Int64) + case apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) + case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) + case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) + case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64]) + case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) + case apiGetNtfToken + case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) + case apiVerifyToken(token: DeviceToken, nonce: String, code: String) + case apiCheckToken(token: DeviceToken) + case apiDeleteToken(token: DeviceToken) + case apiGetNtfConns(nonce: String, encNtfInfo: String) + case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) + case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) + case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) + case apiJoinGroup(groupId: Int64) + case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) + case apiDeleteMemberSupportChat(groupId: Int64, groupMemberId: Int64) + case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) + case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) + case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool) + case apiLeaveGroup(groupId: Int64) + case apiListMembers(groupId: Int64) + case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) + case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole, short: Bool) + case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) + case apiDeleteGroupLink(groupId: Int64) + case apiGetGroupLink(groupId: Int64) + case apiAddGroupShortLink(groupId: Int64) + case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) + case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) + case apiTestProtoServer(userId: Int64, server: String) + case apiGetServerOperators + case apiSetServerOperators(operators: [ServerOperator]) + case apiGetUserServers(userId: Int64) + case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers]) + case apiValidateServers(userId: Int64, userServers: [UserOperatorServers]) + case apiGetUsageConditions + case apiSetConditionsNotified(conditionsId: Int64) + case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) + case apiSetChatItemTTL(userId: Int64, seconds: Int64) + case apiGetChatItemTTL(userId: Int64) + case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?) + case apiSetNetworkConfig(networkConfig: NetCfg) + case apiGetNetworkConfig + case apiSetNetworkInfo(networkInfo: UserNetworkInfo) + case reconnectAllServers + case reconnectServer(userId: Int64, smpServer: String) + case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) + case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) + case apiContactInfo(contactId: Int64) + case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) + case apiContactQueueInfo(contactId: Int64) + case apiGroupMemberQueueInfo(groupId: Int64, groupMemberId: Int64) + case apiSwitchContact(contactId: Int64) + case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64) + case apiAbortSwitchContact(contactId: Int64) + case apiAbortSwitchGroupMember(groupId: Int64, groupMemberId: Int64) + case apiSyncContactRatchet(contactId: Int64, force: Bool) + case apiSyncGroupMemberRatchet(groupId: Int64, groupMemberId: Int64, force: Bool) + case apiGetContactCode(contactId: Int64) + case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) + case apiVerifyContact(contactId: Int64, connectionCode: String?) + case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) + case apiAddContact(userId: Int64, short: Bool, incognito: Bool) + case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiChangeConnectionUser(connId: Int64, userId: Int64) + 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) + case apiListContacts(userId: Int64) + case apiUpdateProfile(userId: Int64, profile: Profile) + case apiSetContactPrefs(contactId: Int64, preferences: Preferences) + case apiSetContactAlias(contactId: Int64, localAlias: String) + case apiSetGroupAlias(groupId: Int64, localAlias: String) + case apiSetConnectionAlias(connId: Int64, localAlias: String) + case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) + case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) + case apiCreateMyAddress(userId: Int64, short: Bool) + case apiDeleteMyAddress(userId: Int64) + case apiShowMyAddress(userId: Int64) + case apiAddMyAddressShortLink(userId: Int64) + case apiSetProfileAddress(userId: Int64, on: Bool) + case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) + case apiAcceptContact(incognito: Bool, contactReqId: Int64) + case apiRejectContact(contactReqId: Int64) + // WebRTC calls + case apiSendCallInvitation(contact: Contact, callType: CallType) + case apiRejectCall(contact: Contact) + case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer) + case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) + case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) + case apiEndCall(contact: Contact) + case apiGetCallInvitations + case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) + // WebRTC calls / + case apiGetNetworkStatuses + case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) + case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) + case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) + case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) + case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) + case cancelFile(fileId: Int64) + // remote desktop commands + case setLocalDeviceName(displayName: String) + case connectRemoteCtrl(xrcpInvitation: String) + case findKnownRemoteCtrl + case confirmRemoteCtrl(remoteCtrlId: Int64) + case verifyRemoteCtrlSession(sessionCode: String) + case listRemoteCtrls + case stopRemoteCtrl + case deleteRemoteCtrl(remoteCtrlId: Int64) + case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) + case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) + case apiStandaloneFileInfo(url: String) + // misc + case showVersion + case getAgentSubsTotal(userId: Int64) + case getAgentServersSummary(userId: Int64) + case resetAgentServersStats + case string(String) + + var cmdString: String { + get { + switch self { + case .showActiveUser: return "/u" + case let .createActiveUser(profile, pastTimestamp): + let user = NewUser(profile: profile, pastTimestamp: pastTimestamp) + return "/_create user \(encodeJSON(user))" + case .listUsers: return "/users" + case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" + case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))" + case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings): + let umrs = userMsgReceiptSettings + return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" + case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings): + let umrs = userMsgReceiptSettings + return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" + case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" + case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" + case let .apiMuteUser(userId): return "/_mute user \(userId)" + case let .apiUnmuteUser(userId): return "/_unmute user \(userId)" + case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" + case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" + case .checkChatRunning: return "/_check running" + case .apiStopChat: return "/_stop" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" + case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" + case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" + case .apiDeleteStorage: return "/_db delete" + case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" + case let .testStorageEncryption(key): return "/db test key \(key)" + case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))" + case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))" + case let .apiGetChatTags(userId): return "/_get tags \(userId)" + case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" + case let .apiGetChat(chatId, scope, contentTag, pagination, search): + let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : "" + return "/_get chat \(chatId)\(scopeRef(scope: scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") + case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" + case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + let msgs = encodeJSON(composedMessages) + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" + case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" + case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))" + case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiCreateChatItems(noteFolderId, composedMessages): + let msgs = encodeJSON(composedMessages) + return "/_create *\(noteFolderId) json \(msgs)" + case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): + return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" + case let .apiUpdateChatItem(type, id, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)" + case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" + case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiArchiveReceivedReports(groupId): return "/_archive reports #\(groupId)" + case let .apiDeleteReceivedReports(groupId, itemIds, mode): return "/_delete reports #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" + case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" + case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" + case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl): + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + case .apiGetNtfToken: return "/_ntf get " + case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" + case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" + case let .apiCheckToken(token): return "/_ntf check \(token.cmdString)" + case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" + case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" + case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" + case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" + case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" + case let .apiJoinGroup(groupId): return "/_join #\(groupId)" + case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" + case let .apiDeleteMemberSupportChat(groupId, groupMemberId): return "/_delete member chat #\(groupId) \(groupMemberId)" + case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)" + case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))" + case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))" + 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, 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)" + case let .apiAddGroupShortLink(groupId): return "/_short link #\(groupId)" + case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" + case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" + case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case .apiGetServerOperators: return "/_operators" + case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" + case let .apiGetUserServers(userId): return "/_servers \(userId)" + case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))" + case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))" + case .apiGetUsageConditions: return "/_conditions" + case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)" + case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" + case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" + case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" + case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id, scope: nil)) \(chatItemTTLStr(seconds: seconds))" + case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" + case .apiGetNetworkConfig: return "/network" + case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" + case .reconnectAllServers: return "/reconnect" + case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" + case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))" + case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" + case let .apiContactInfo(contactId): return "/_info @\(contactId)" + case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" + case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)" + case let .apiGroupMemberQueueInfo(groupId, groupMemberId): return "/_queue info #\(groupId) \(groupMemberId)" + case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" + case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)" + case let .apiAbortSwitchContact(contactId): return "/_abort switch @\(contactId)" + case let .apiAbortSwitchGroupMember(groupId, groupMemberId): return "/_abort switch #\(groupId) \(groupMemberId)" + case let .apiSyncContactRatchet(contactId, force): if force { + return "/_sync @\(contactId) force=on" + } else { + return "/_sync @\(contactId)" + } + case let .apiSyncGroupMemberRatchet(groupId, groupMemberId, force): if force { + return "/_sync #\(groupId) \(groupMemberId) force=on" + } else { + return "/_sync #\(groupId) \(groupMemberId)" + } + case let .apiGetContactCode(contactId): return "/_get code @\(contactId)" + case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)" + case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)" + 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, 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, 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, scope: nil)) \(chatDeleteMode.cmdString)" + case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))" + case let .apiListContacts(userId): return "/_contacts \(userId)" + case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" + case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" + case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" + case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))" + 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, 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 .apiAddMyAddressShortLink(userId): return "/_short_link_address \(userId)" + case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" + case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" + case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" + case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" + case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" + case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))" + case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" + case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" + case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" + case .apiGetCallInvitations: return "/_call get" + case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" + case .apiGetNetworkStatuses: return "/_network_statuses" + case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))" + case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))" + case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))" + case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" + case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))" + case let .cancelFile(fileId): return "/fcancel \(fileId)" + case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" + case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)" + case .findKnownRemoteCtrl: return "/find remote ctrl" + case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)" + case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)" + case .listRemoteCtrls: return "/list remote ctrls" + case .stopRemoteCtrl: return "/stop remote ctrl" + case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" + case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" + case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" + case let .apiStandaloneFileInfo(link): return "/_download info \(link)" + case .showVersion: return "/version" + case let .getAgentSubsTotal(userId): return "/get subs total \(userId)" + case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" + case .resetAgentServersStats: return "/reset servers stats" + case let .string(str): return str + } + } + } + + var cmdType: String { + get { + switch self { + case .showActiveUser: return "showActiveUser" + case .createActiveUser: return "createActiveUser" + case .listUsers: return "listUsers" + case .apiSetActiveUser: return "apiSetActiveUser" + case .setAllContactReceipts: return "setAllContactReceipts" + case .apiSetUserContactReceipts: return "apiSetUserContactReceipts" + case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts" + case .apiHideUser: return "apiHideUser" + case .apiUnhideUser: return "apiUnhideUser" + case .apiMuteUser: return "apiMuteUser" + case .apiUnmuteUser: return "apiUnmuteUser" + case .apiDeleteUser: return "apiDeleteUser" + case .startChat: return "startChat" + case .checkChatRunning: return "checkChatRunning" + case .apiStopChat: return "apiStopChat" + case .apiActivateChat: return "apiActivateChat" + case .apiSuspendChat: return "apiSuspendChat" + case .apiSetAppFilePaths: return "apiSetAppFilePaths" + case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" + case .apiExportArchive: return "apiExportArchive" + case .apiImportArchive: return "apiImportArchive" + case .apiDeleteStorage: return "apiDeleteStorage" + case .apiStorageEncryption: return "apiStorageEncryption" + case .testStorageEncryption: return "testStorageEncryption" + case .apiSaveSettings: return "apiSaveSettings" + case .apiGetSettings: return "apiGetSettings" + case .apiGetChatTags: return "apiGetChatTags" + case .apiGetChats: return "apiGetChats" + case .apiGetChat: return "apiGetChat" + case .apiGetChatItemInfo: return "apiGetChatItemInfo" + case .apiSendMessages: return "apiSendMessages" + case .apiCreateChatTag: return "apiCreateChatTag" + case .apiSetChatTags: return "apiSetChatTags" + case .apiDeleteChatTag: return "apiDeleteChatTag" + case .apiUpdateChatTag: return "apiUpdateChatTag" + case .apiReorderChatTags: return "apiReorderChatTags" + case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" + case .apiUpdateChatItem: return "apiUpdateChatItem" + case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" + case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" + case .apiArchiveReceivedReports: return "apiArchiveReceivedReports" + case .apiDeleteReceivedReports: return "apiDeleteReceivedReports" + case .apiChatItemReaction: return "apiChatItemReaction" + case .apiGetReactionMembers: return "apiGetReactionMembers" + case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" + case .apiForwardChatItems: return "apiForwardChatItems" + case .apiGetNtfToken: return "apiGetNtfToken" + case .apiRegisterToken: return "apiRegisterToken" + case .apiVerifyToken: return "apiVerifyToken" + case .apiCheckToken: return "apiCheckToken" + case .apiDeleteToken: return "apiDeleteToken" + case .apiGetNtfConns: return "apiGetNtfConns" + case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" + case .apiNewGroup: return "apiNewGroup" + case .apiAddMember: return "apiAddMember" + case .apiJoinGroup: return "apiJoinGroup" + case .apiAcceptMember: return "apiAcceptMember" + case .apiDeleteMemberSupportChat: return "apiDeleteMemberSupportChat" + case .apiMembersRole: return "apiMembersRole" + case .apiBlockMembersForAll: return "apiBlockMembersForAll" + case .apiRemoveMembers: return "apiRemoveMembers" + case .apiLeaveGroup: return "apiLeaveGroup" + case .apiListMembers: return "apiListMembers" + case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" + case .apiCreateGroupLink: return "apiCreateGroupLink" + case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" + case .apiDeleteGroupLink: return "apiDeleteGroupLink" + case .apiGetGroupLink: return "apiGetGroupLink" + case .apiAddGroupShortLink: return "apiAddGroupShortLink" + case .apiCreateMemberContact: return "apiCreateMemberContact" + case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" + case .apiTestProtoServer: return "apiTestProtoServer" + case .apiGetServerOperators: return "apiGetServerOperators" + case .apiSetServerOperators: return "apiSetServerOperators" + case .apiGetUserServers: return "apiGetUserServers" + case .apiSetUserServers: return "apiSetUserServers" + case .apiValidateServers: return "apiValidateServers" + case .apiGetUsageConditions: return "apiGetUsageConditions" + case .apiSetConditionsNotified: return "apiSetConditionsNotified" + case .apiAcceptConditions: return "apiAcceptConditions" + case .apiSetChatItemTTL: return "apiSetChatItemTTL" + case .apiGetChatItemTTL: return "apiGetChatItemTTL" + case .apiSetChatTTL: return "apiSetChatTTL" + case .apiSetNetworkConfig: return "apiSetNetworkConfig" + case .apiGetNetworkConfig: return "apiGetNetworkConfig" + case .apiSetNetworkInfo: return "apiSetNetworkInfo" + case .reconnectAllServers: return "reconnectAllServers" + case .reconnectServer: return "reconnectServer" + case .apiSetChatSettings: return "apiSetChatSettings" + case .apiSetMemberSettings: return "apiSetMemberSettings" + case .apiContactInfo: return "apiContactInfo" + case .apiGroupMemberInfo: return "apiGroupMemberInfo" + case .apiContactQueueInfo: return "apiContactQueueInfo" + case .apiGroupMemberQueueInfo: return "apiGroupMemberQueueInfo" + case .apiSwitchContact: return "apiSwitchContact" + case .apiSwitchGroupMember: return "apiSwitchGroupMember" + case .apiAbortSwitchContact: return "apiAbortSwitchContact" + case .apiAbortSwitchGroupMember: return "apiAbortSwitchGroupMember" + case .apiSyncContactRatchet: return "apiSyncContactRatchet" + case .apiSyncGroupMemberRatchet: return "apiSyncGroupMemberRatchet" + case .apiGetContactCode: return "apiGetContactCode" + case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" + case .apiVerifyContact: return "apiVerifyContact" + case .apiVerifyGroupMember: return "apiVerifyGroupMember" + case .apiAddContact: return "apiAddContact" + case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" + case .apiChangeConnectionUser: return "apiChangeConnectionUser" + case .apiConnectPlan: return "apiConnectPlan" + case .apiConnect: return "apiConnect" + case .apiDeleteChat: return "apiDeleteChat" + case .apiClearChat: return "apiClearChat" + case .apiListContacts: return "apiListContacts" + case .apiUpdateProfile: return "apiUpdateProfile" + case .apiSetContactPrefs: return "apiSetContactPrefs" + case .apiSetContactAlias: return "apiSetContactAlias" + case .apiSetGroupAlias: return "apiSetGroupAlias" + case .apiSetConnectionAlias: return "apiSetConnectionAlias" + case .apiSetUserUIThemes: return "apiSetUserUIThemes" + case .apiSetChatUIThemes: return "apiSetChatUIThemes" + case .apiCreateMyAddress: return "apiCreateMyAddress" + case .apiDeleteMyAddress: return "apiDeleteMyAddress" + case .apiShowMyAddress: return "apiShowMyAddress" + case .apiAddMyAddressShortLink: return "apiAddMyAddressShortLink" + case .apiSetProfileAddress: return "apiSetProfileAddress" + case .apiAddressAutoAccept: return "apiAddressAutoAccept" + case .apiAcceptContact: return "apiAcceptContact" + case .apiRejectContact: return "apiRejectContact" + case .apiSendCallInvitation: return "apiSendCallInvitation" + case .apiRejectCall: return "apiRejectCall" + case .apiSendCallOffer: return "apiSendCallOffer" + case .apiSendCallAnswer: return "apiSendCallAnswer" + case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" + case .apiEndCall: return "apiEndCall" + case .apiGetCallInvitations: return "apiGetCallInvitations" + case .apiCallStatus: return "apiCallStatus" + case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" + case .apiChatRead: return "apiChatRead" + case .apiChatItemsRead: return "apiChatItemsRead" + case .apiChatUnread: return "apiChatUnread" + case .receiveFile: return "receiveFile" + case .setFileToReceive: return "setFileToReceive" + case .cancelFile: return "cancelFile" + case .setLocalDeviceName: return "setLocalDeviceName" + case .connectRemoteCtrl: return "connectRemoteCtrl" + case .findKnownRemoteCtrl: return "findKnownRemoteCtrl" + case .confirmRemoteCtrl: return "confirmRemoteCtrl" + case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession" + case .listRemoteCtrls: return "listRemoteCtrls" + case .stopRemoteCtrl: return "stopRemoteCtrl" + case .deleteRemoteCtrl: return "deleteRemoteCtrl" + case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" + case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" + case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" + case .showVersion: return "showVersion" + case .getAgentSubsTotal: return "getAgentSubsTotal" + case .getAgentServersSummary: return "getAgentServersSummary" + case .resetAgentServersStats: return "resetAgentServersStats" + case .string: return "console command" + } + } + } + + func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String { + "\(type.rawValue)\(id)\(scopeRef(scope: scope))" + } + + func scopeRef(scope: GroupChatScope?) -> String { + switch (scope) { + case .none: "" + case let .memberSupport(groupMemberId_): + if let groupMemberId = groupMemberId_ { + "(_support:\(groupMemberId))" + } else { + "(_support)" + } + } + } + + func joinedIds(_ ids: [Int64]) -> String { + ids.map { "\($0)" }.joined(separator: ",") + } + + func chatItemTTLStr(seconds: Int64?) -> String { + if let seconds = seconds { + return String(seconds) + } else { + return "default" + } + } + + var obfuscated: ChatCommand { + switch self { + case let .apiStorageEncryption(cfg): + return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey))) + case let .apiSetActiveUser(userId, viewPwd): + return .apiSetActiveUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiHideUser(userId, viewPwd): + return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiUnhideUser(userId, viewPwd): + return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiDeleteUser(userId, delSMPQueues, viewPwd): + return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) + case let .testStorageEncryption(key): + return .testStorageEncryption(key: obfuscate(key)) + default: return self + } + } + + private func obfuscate(_ s: String) -> String { + s == "" ? "" : "***" + } + + private func obfuscate(_ s: String?) -> String? { + if let s = s { + return obfuscate(s) + } + return nil + } + + private func onOffParam(_ param: String, _ b: Bool?) -> String { + if let b = b { + return " \(param)=\(onOff(b))" + } + return "" + } + + private func maybePwd(_ pwd: String?) -> String { + pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) + } +} + +// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. +enum ChatResponse0: Decodable, ChatAPIResult { + case activeUser(user: User) + case usersList(users: [UserInfo]) + case chatStarted + case chatRunning + case chatStopped + case apiChats(user: UserRef, chats: [ChatData]) + case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?) + case chatTags(user: UserRef, userTags: [ChatTag]) + case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) + case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case serverOperatorConditions(conditions: ServerOperatorConditions) + case userServers(user: UserRef, userServers: [UserOperatorServers]) + case userServersValidation(user: UserRef, serverErrors: [UserServersError]) + case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) + case chatItemTTL(user: UserRef, chatItemTTL: Int64?) + case networkConfig(networkConfig: NetCfg) + case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) + case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) + case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo) + case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) + case groupMemberSwitchStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactSwitchAborted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) + case groupMemberSwitchAborted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactRatchetSyncStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) + case groupMemberRatchetSyncStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactCode(user: UserRef, contact: Contact, connectionCode: String) + 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]) + + var responseType: String { + switch self { + case .activeUser: "activeUser" + case .usersList: "usersList" + case .chatStarted: "chatStarted" + case .chatRunning: "chatRunning" + case .chatStopped: "chatStopped" + case .apiChats: "apiChats" + case .apiChat: "apiChat" + case .chatTags: "chatTags" + case .chatItemInfo: "chatItemInfo" + case .serverTestResult: "serverTestResult" + case .serverOperatorConditions: "serverOperators" + case .userServers: "userServers" + case .userServersValidation: "userServersValidation" + case .usageConditions: "usageConditions" + case .chatItemTTL: "chatItemTTL" + case .networkConfig: "networkConfig" + case .contactInfo: "contactInfo" + case .groupMemberInfo: "groupMemberInfo" + case .queueInfo: "queueInfo" + case .contactSwitchStarted: "contactSwitchStarted" + case .groupMemberSwitchStarted: "groupMemberSwitchStarted" + case .contactSwitchAborted: "contactSwitchAborted" + case .groupMemberSwitchAborted: "groupMemberSwitchAborted" + case .contactRatchetSyncStarted: "contactRatchetSyncStarted" + case .groupMemberRatchetSyncStarted: "groupMemberRatchetSyncStarted" + case .contactCode: "contactCode" + case .groupMemberCode: "groupMemberCode" + case .connectionVerified: "connectionVerified" + case .tagsUpdated: "tagsUpdated" + } + } + + var details: String { + switch self { + case let .activeUser(user): return String(describing: user) + case let .usersList(users): return String(describing: users) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case .chatStopped: return noDetails + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") + case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") + case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") + case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" + case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") + case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" + case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) + case let .networkConfig(networkConfig): return String(describing: networkConfig) + case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") + case let .queueInfo(u, rcvMsgInfo, queueInfo): + let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } + return withUser(u, "rcvMsgInfo: \(msgInfo)\nqueueInfo: \(encodeJSON(queueInfo))") + case let .contactSwitchStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") + 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))") + } + } + + static func fallbackResult(_ type: String, _ json: NSDictionary) -> ChatResponse0? { + if type == "apiChats" { + if let r = parseApiChats(json) { + return .apiChats(user: r.user, chats: r.chats) + } + } else if type == "apiChat" { + if let jApiChat = json["apiChat"] as? NSDictionary, + let user: UserRef = try? decodeObject(jApiChat["user"] as Any), + let jChat = jApiChat["chat"] as? NSDictionary, + let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) { + return .apiChat(user: user, chat: chat, navInfo: navInfo) + } + } + return nil + } +} + +enum ChatResponse1: Decodable, ChatAPIResult { + 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, connLink: CreatedConnLink, connectionPlan: ConnectionPlan) + case sentConfirmation(user: UserRef, connection: PendingContactConnection) + case sentInvitation(user: UserRef, connection: PendingContactConnection) + case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) + case contactAlreadyExists(user: UserRef, contact: Contact) + case contactDeleted(user: UserRef, contact: Contact) + case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) + case itemsReadForChat(user: UserRef, chatInfo: ChatInfo) + case chatCleared(user: UserRef, chatInfo: ChatInfo) + case userProfileNoChange(user: User) + case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) + case userPrivacy(user: User, updatedUser: User) + case contactAliasUpdated(user: UserRef, toContact: Contact) + case groupAliasUpdated(user: UserRef, toGroup: GroupInfo) + case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection) + case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) + case userContactLink(user: User, contactLink: UserContactLink) + case userContactLinkUpdated(user: User, contactLink: UserContactLink) + case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink) + case userContactLinkDeleted(user: User) + case acceptingContactRequest(user: UserRef, contact: Contact) + case contactRequestRejected(user: UserRef) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) + case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) + case chatItemUpdated(user: UserRef, chatItem: AChatItem) + case chatItemNotChanged(user: UserRef, chatItem: AChatItem) + case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) + case reactionMembers(user: UserRef, memberReactions: [MemberReaction]) + case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) + case contactsList(user: UserRef, contacts: [Contact]) + + var responseType: String { + switch self { + case .invitation: "invitation" + case .connectionIncognitoUpdated: "connectionIncognitoUpdated" + case .connectionUserChanged: "connectionUserChanged" + case .connectionPlan: "connectionPlan" + case .sentConfirmation: "sentConfirmation" + case .sentInvitation: "sentInvitation" + case .sentInvitationToContact: "sentInvitationToContact" + case .contactAlreadyExists: "contactAlreadyExists" + case .contactDeleted: "contactDeleted" + case .contactConnectionDeleted: "contactConnectionDeleted" + case .groupDeletedUser: "groupDeletedUser" + case .itemsReadForChat: "itemsReadForChat" + case .chatCleared: "chatCleared" + case .userProfileNoChange: "userProfileNoChange" + case .userProfileUpdated: "userProfileUpdated" + case .userPrivacy: "userPrivacy" + case .contactAliasUpdated: "contactAliasUpdated" + case .groupAliasUpdated: "groupAliasUpdated" + case .connectionAliasUpdated: "connectionAliasUpdated" + case .contactPrefsUpdated: "contactPrefsUpdated" + case .userContactLink: "userContactLink" + case .userContactLinkUpdated: "userContactLinkUpdated" + case .userContactLinkCreated: "userContactLinkCreated" + case .userContactLinkDeleted: "userContactLinkDeleted" + case .acceptingContactRequest: "acceptingContactRequest" + case .contactRequestRejected: "contactRequestRejected" + case .networkStatuses: "networkStatuses" + case .newChatItems: "newChatItems" + case .groupChatItemsDeleted: "groupChatItemsDeleted" + case .forwardPlan: "forwardPlan" + case .chatItemUpdated: "chatItemUpdated" + case .chatItemNotChanged: "chatItemNotChanged" + case .chatItemReaction: "chatItemReaction" + case .reactionMembers: "reactionMembers" + case .chatItemsDeleted: "chatItemsDeleted" + case .contactsList: "contactsList" + } + } + + var details: String { + switch self { + case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) + case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .itemsReadForChat(u, chatInfo): return withUser(u, String(describing: chatInfo)) + case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) + case .userProfileNoChange: return noDetails + case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) + case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) + case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + 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, connLink): return withUser(u, String(describing: connLink)) + case .userContactLinkDeleted: return noDetails + case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) + case .contactRequestRejected: return noDetails + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): + return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") + case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") + case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))") + case let .chatItemsDeleted(u, items, byUser): + let itemsString = items.map { item in + "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") + return withUser(u, itemsString + "\nbyUser: \(byUser)") + case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) + 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))\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)) + case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) + } + } +} + +enum ChatResponse2: Decodable, ChatAPIResult { + // group responses + case groupCreated(user: UserRef, groupInfo: GroupInfo) + case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) + case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) + case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) + case leftMemberUser(user: UserRef, groupInfo: GroupInfo) + case groupMembers(user: UserRef, group: SimpleXChat.Group) + case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) + case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) + case groupUpdated(user: UserRef, toGroup: GroupInfo) + 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) + // receiving file responses + case rcvFileAccepted(user: UserRef, chatItem: AChatItem) + case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case standaloneFileInfo(fileMeta: MigrationFileLinkData?) + case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) + // sending file responses + case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload + case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used + case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) + // call invitations + case callInvitations(callInvitations: [RcvCallInvitation]) + // notifications + case ntfTokenStatus(status: NtfTknStatus) + case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) + case ntfConns(ntfConns: [NtfConn]) + case connNtfMessages(receivedMsgs: [RcvNtfMsgInfo]) + // remote desktop responses + case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) + case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) + case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) + // misc + case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) + case cmdOk(user_: UserRef?) + case agentSubsTotal(user: UserRef, subsTotal: SMPServerSubs, hasSession: Bool) + case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary) + case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) + case archiveExported(archiveErrors: [ArchiveError]) + case archiveImported(archiveErrors: [ArchiveError]) + case appSettings(appSettings: AppSettings) + + var responseType: String { + switch self { + case .groupCreated: "groupCreated" + case .sentGroupInvitation: "sentGroupInvitation" + case .userAcceptedGroupSent: "userAcceptedGroupSent" + case .userDeletedMembers: "userDeletedMembers" + case .leftMemberUser: "leftMemberUser" + case .groupMembers: "groupMembers" + case .memberAccepted: "memberAccepted" + case .memberSupportChatDeleted: "memberSupportChatDeleted" + case .membersRoleUser: "membersRoleUser" + case .membersBlockedForAllUser: "membersBlockedForAllUser" + case .groupUpdated: "groupUpdated" + case .groupLinkCreated: "groupLinkCreated" + case .groupLink: "groupLink" + case .groupLinkDeleted: "groupLinkDeleted" + case .newMemberContact: "newMemberContact" + case .newMemberContactSentInv: "newMemberContactSentInv" + case .rcvFileAccepted: "rcvFileAccepted" + case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" + case .standaloneFileInfo: "standaloneFileInfo" + case .rcvStandaloneFileCreated: "rcvStandaloneFileCreated" + case .rcvFileCancelled: "rcvFileCancelled" + case .sndFileCancelled: "sndFileCancelled" + case .sndStandaloneFileCreated: "sndStandaloneFileCreated" + case .sndFileStartXFTP: "sndFileStartXFTP" + case .sndFileCancelledXFTP: "sndFileCancelledXFTP" + case .callInvitations: "callInvitations" + case .ntfTokenStatus: "ntfTokenStatus" + case .ntfToken: "ntfToken" + case .ntfConns: "ntfConns" + case .connNtfMessages: "connNtfMessages" + case .remoteCtrlList: "remoteCtrlList" + case .remoteCtrlConnecting: "remoteCtrlConnecting" + case .remoteCtrlConnected: "remoteCtrlConnected" + case .versionInfo: "versionInfo" + case .cmdOk: "cmdOk" + case .agentSubsTotal: "agentSubsTotal" + case .agentServersSummary: "agentServersSummary" + case .agentSubsSummary: "agentSubsSummary" + case .archiveExported: "archiveExported" + case .archiveImported: "archiveImported" + case .appSettings: "appSettings" + } + } + + var details: String { + switch self { + case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") + case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupMembers(u, group): return withUser(u, String(describing: group)) + case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") + case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") + case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + 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)") + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) + case .rcvFileAcceptedSndCancelled: return noDetails + case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) + case .rcvStandaloneFileCreated: return noDetails + case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) + case .sndStandaloneFileCreated: return noDetails + case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .callInvitations(invs): return String(describing: invs) + case let .ntfTokenStatus(status): return String(describing: status) + case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" + case let .ntfConns(ntfConns): return String(describing: ntfConns) + case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" + case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) + case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" + case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) + case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" + case .cmdOk: return noDetails + case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)") + case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) + case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) + case let .archiveExported(archiveErrors): return String(describing: archiveErrors) + case let .archiveImported(archiveErrors): return String(describing: archiveErrors) + case let .appSettings(appSettings): return String(describing: appSettings) + } + } +} + +enum ChatEvent: Decodable, ChatAPIResult { + case chatSuspended + case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) + case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) + case contactRatchetSync(user: UserRef, contact: Contact, ratchetSyncProgress: RatchetSyncProgress) + case groupMemberRatchetSync(user: UserRef, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress) + case contactDeletedByContact(user: UserRef, contact: Contact) + case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) + case contactConnecting(user: UserRef, contact: Contact) + case contactSndReady(user: UserRef, contact: Contact) + case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) + case contactUpdated(user: UserRef, toContact: Contact) + case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) + case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) + case networkStatus(networkStatus: NetworkStatus, connections: [String]) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) + case chatItemUpdated(user: UserRef, chatItem: AChatItem) + case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) + case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) + // group events + case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) + case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) + case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) + case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) + case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) + case memberAcceptedByOther(user: UserRef, groupInfo: GroupInfo, acceptingMember: GroupMember, member: GroupMember) + case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool) + case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool) + case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) + case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) + case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) + case groupUpdated(user: UserRef, toGroup: GroupInfo) + case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) + // receiving file events + case rcvFileAccepted(user: UserRef, chatItem: AChatItem) + case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats + case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) + case rcvFileComplete(user: UserRef, chatItem: AChatItem) + case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) + case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) + case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) + // sending file events + case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) + case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) + case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + // call events + case callInvitation(callInvitation: RcvCallInvitation) + case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(user: UserRef, contact: Contact, answer: WebRTCSession) + case callExtraInfo(user: UserRef, contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(user: UserRef, contact: Contact) + case contactDisabled(user: UserRef, contact: Contact) + // notification marker + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) + // remote desktop responses + case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) + case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) + case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) + // pq + case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) + + var responseType: String { + switch self { + case .chatSuspended: "chatSuspended" + case .contactSwitch: "contactSwitch" + case .groupMemberSwitch: "groupMemberSwitch" + case .contactRatchetSync: "contactRatchetSync" + case .groupMemberRatchetSync: "groupMemberRatchetSync" + case .contactDeletedByContact: "contactDeletedByContact" + case .contactConnected: "contactConnected" + case .contactConnecting: "contactConnecting" + case .contactSndReady: "contactSndReady" + case .receivedContactRequest: "receivedContactRequest" + case .contactUpdated: "contactUpdated" + case .groupMemberUpdated: "groupMemberUpdated" + case .contactsMerged: "contactsMerged" + case .networkStatus: "networkStatus" + case .networkStatuses: "networkStatuses" + case .newChatItems: "newChatItems" + case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" + case .chatItemUpdated: "chatItemUpdated" + case .chatItemReaction: "chatItemReaction" + case .chatItemsDeleted: "chatItemsDeleted" + case .groupChatItemsDeleted: "groupChatItemsDeleted" + case .receivedGroupInvitation: "receivedGroupInvitation" + case .userAcceptedGroupSent: "userAcceptedGroupSent" + case .groupLinkConnecting: "groupLinkConnecting" + case .businessLinkConnecting: "businessLinkConnecting" + case .joinedGroupMemberConnecting: "joinedGroupMemberConnecting" + case .memberAcceptedByOther: "memberAcceptedByOther" + case .memberRole: "memberRole" + case .memberBlockedForAll: "memberBlockedForAll" + case .deletedMemberUser: "deletedMemberUser" + case .deletedMember: "deletedMember" + case .leftMember: "leftMember" + case .groupDeleted: "groupDeleted" + case .userJoinedGroup: "userJoinedGroup" + case .joinedGroupMember: "joinedGroupMember" + case .connectedToGroupMember: "connectedToGroupMember" + case .groupUpdated: "groupUpdated" + case .newMemberContactReceivedInv: "newMemberContactReceivedInv" + case .rcvFileAccepted: "rcvFileAccepted" + case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" + case .rcvFileStart: "rcvFileStart" + case .rcvFileProgressXFTP: "rcvFileProgressXFTP" + case .rcvFileComplete: "rcvFileComplete" + case .rcvStandaloneFileComplete: "rcvStandaloneFileComplete" + case .rcvFileSndCancelled: "rcvFileSndCancelled" + case .rcvFileError: "rcvFileError" + case .rcvFileWarning: "rcvFileWarning" + case .sndFileStart: "sndFileStart" + case .sndFileComplete: "sndFileComplete" + case .sndFileRcvCancelled: "sndFileRcvCancelled" + case .sndFileProgressXFTP: "sndFileProgressXFTP" + case .sndFileRedirectStartXFTP: "sndFileRedirectStartXFTP" + case .sndFileCompleteXFTP: "sndFileCompleteXFTP" + case .sndStandaloneFileComplete: "sndStandaloneFileComplete" + case .sndFileError: "sndFileError" + case .sndFileWarning: "sndFileWarning" + case .callInvitation: "callInvitation" + case .callOffer: "callOffer" + case .callAnswer: "callAnswer" + case .callExtraInfo: "callExtraInfo" + case .callEnded: "callEnded" + case .contactDisabled: "contactDisabled" + case .ntfMessage: "ntfMessage" + case .remoteCtrlFound: "remoteCtrlFound" + case .remoteCtrlSessionCode: "remoteCtrlSessionCode" + case .remoteCtrlConnected: "remoteCtrlConnected" + case .remoteCtrlStopped: "remoteCtrlStopped" + case .contactPQEnabled: "contactPQEnabled" + } + } + + var details: String { + switch self { + case .chatSuspended: return noDetails + case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") + case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") + case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) + case let .contactSndReady(u, contact): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") + case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") + case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .chatItemsStatusesUpdated(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") + case let .chatItemsDeleted(u, items, byUser): + let itemsString = items.map { item in + "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") + return withUser(u, itemsString + "\nbyUser: \(byUser)") + case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): + return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") + case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") + case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") + case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") + case let .memberAcceptedByOther(u, groupInfo, acceptingMember, member): return withUser(u, "groupInfo: \(groupInfo)\nacceptingMember: \(acceptingMember)\nmember: \(member)") + case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)") + case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)") + case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") + case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") + case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) + case .rcvFileAcceptedSndCancelled: return noDetails + case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") + case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) + case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") + case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") + case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) + case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) + case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .callInvitation(inv): return String(describing: inv) + case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") + case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") + case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") + case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") + case let .contactDisabled(u, contact): return withUser(u, String(describing: contact)) + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") + case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" + case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" + case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" + case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") + } + } +} + +struct NewUser: Encodable { + var profile: Profile? + var pastTimestamp: Bool +} + +enum ChatPagination { + static let INITIAL_COUNT = 75 + static let PRELOAD_COUNT = 100 + static let UNTIL_PRELOAD_COUNT = 50 + + case last(count: Int) + case after(chatItemId: Int64, count: Int) + case before(chatItemId: Int64, count: Int) + case around(chatItemId: Int64, count: Int) + case initial(count: Int) + + var cmdString: String { + switch self { + case let .last(count): return "count=\(count)" + case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" + case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" + case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" + case let .initial(count): return "initial=\(count)" + } + } +} + +enum ConnectionPlan: Decodable, Hashable { + case invitationLink(invitationLinkPlan: InvitationLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan) + case groupLink(groupLinkPlan: GroupLinkPlan) + case error(chatError: ChatError) +} + +enum InvitationLinkPlan: Decodable, Hashable { + case ok + case ownLink + case connecting(contact_: Contact?) + case known(contact: Contact) +} + +enum ContactAddressPlan: Decodable, Hashable { + case ok + case ownLink + case connectingConfirmReconnect + case connectingProhibit(contact: Contact) + case known(contact: Contact) + case contactViaAddress(contact: Contact) +} + +enum GroupLinkPlan: Decodable, Hashable { + case ok + case ownLink(groupInfo: GroupInfo) + case connectingConfirmReconnect + case connectingProhibit(groupInfo_: GroupInfo?) + case known(groupInfo: GroupInfo) +} + +struct ChatTagData: Encodable { + var emoji: String? + var text: String +} + +struct UpdatedMessage: Encodable { + var msgContent: MsgContent + var mentions: [String: Int64] + + var cmdString: String { + "json \(encodeJSON(self))" + } +} + +enum ChatDeleteMode: Codable { + case full(notify: Bool) + case entity(notify: Bool) + case messages + + var cmdString: String { + switch self { + case let .full(notify): "full notify=\(onOff(notify))" + case let .entity(notify): "entity notify=\(onOff(notify))" + case .messages: "messages" + } + } + + var isEntity: Bool { + switch self { + case .entity: return true + default: return false + } + } +} + +enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(connectionError: String) + + var statusString: LocalizedStringKey { + switch self { + case .connected: "connected" + case .error: "error" + default: "connecting" + } + } + + var statusExplanation: LocalizedStringKey { + switch self { + case .connected: "You are connected to the server used to receive messages from this contact." + case let .error(err): "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: "Trying to connect to the server used to receive messages from this contact." + } + } + + var imageName: String { + switch self { + case .unknown: "circle.dotted" + case .connected: "circle.fill" + case .disconnected: "ellipsis.circle.fill" + case .error: "exclamationmark.circle.fill" + } + } +} + +enum ForwardConfirmation: Decodable, Hashable { + case filesNotAccepted(fileIds: [Int64]) + case filesInProgress(filesCount: Int) + case filesMissing(filesCount: Int) + case filesFailed(filesCount: Int) +} + +struct ConnNetworkStatus: Decodable { + var agentConnId: String + var networkStatus: NetworkStatus +} + +struct UserMsgReceiptSettings: Codable { + var enable: Bool + var clearOverrides: Bool +} + + +struct UserContactLink: Decodable, Hashable { + var connLinkContact: CreatedConnLink + var autoAccept: AutoAccept? + + var responseDetails: String { + "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" + } +} + +struct AutoAccept: Codable, Hashable { + var businessAddress: Bool + var acceptIncognito: Bool + var autoReply: MsgContent? + + static func cmdString(_ autoAccept: AutoAccept?) -> String { + guard let autoAccept = autoAccept else { return "off" } + var s = "on" + if autoAccept.acceptIncognito { + s += " incognito=on" + } else if autoAccept.businessAddress { + s += " business" + } + guard let msg = autoAccept.autoReply else { return s } + return s + " " + msg.cmdString + } +} + +struct DeviceToken: Decodable { + var pushProvider: PushProvider + var token: String + + var cmdString: String { + "\(pushProvider) \(token)" + } +} + +enum PushEnvironment: String { + case development + case production +} + +enum PushProvider: String, Decodable { + case apns_dev + case apns_prod + + init(env: PushEnvironment) { + switch env { + case .development: self = .apns_dev + case .production: self = .apns_prod + } + } +} + +// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable, +// and .local for periodic background checks +enum NotificationsMode: String, Decodable, SelectableItem { + case off = "OFF" + case periodic = "PERIODIC" + case instant = "INSTANT" + + var label: LocalizedStringKey { + switch self { + case .off: "No push server" + case .periodic: "Periodic" + case .instant: "Instant" + } + } + + var icon: String { + switch self { + case .off: return "arrow.clockwise" + case .periodic: return "timer" + case .instant: return "bolt" + } + } + + var id: String { self.rawValue } + + static var values: [NotificationsMode] = [.instant, .periodic, .off] +} + +struct RemoteCtrlInfo: Decodable { + var remoteCtrlId: Int64 + var ctrlDeviceName: String + var sessionState: RemoteCtrlSessionState? + + var deviceViewName: String { + ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName + } +} + +enum RemoteCtrlSessionState: Decodable { + case starting + case searching + case connecting + case pendingConfirmation(sessionCode: String) + case connected(sessionCode: String) +} + +enum RemoteCtrlStopReason: Decodable { + case discoveryFailed(chatError: ChatError) + case connectionFailed(chatError: ChatError) + case setupFailed(chatError: ChatError) + case disconnected +} + +struct CtrlAppInfo: Decodable { + var appVersionRange: AppVersionRange + var deviceName: String +} + +struct AppVersionRange: Decodable { + var minVersion: String + var maxVersion: String +} + +struct CoreVersionInfo: Decodable { + var version: String + var simplexmqVersion: String + var simplexmqCommit: String +} + +struct ArchiveConfig: Encodable { + var archivePath: String + var disableCompression: Bool? +} + +struct DBEncryptionConfig: Codable { + var currentKey: String + var newKey: String +} + +enum OperatorTag: String, Codable { + case simplex = "simplex" + case flux = "flux" +} + +struct ServerOperatorInfo { + var description: [String] + var website: URL + var selfhost: (text: String, link: URL)? = nil + var logo: String + var largeLogo: String + var logoDarkMode: String + var largeLogoDarkMode: String +} + +let operatorsInfo: Dictionary = [ + .simplex: ServerOperatorInfo( + description: [ + "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or identity keys.", + "SimpleX Chat Ltd develops the communication software for SimpleX network." + ], + website: URL(string: "https://simplex.chat")!, + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ), + .flux: ServerOperatorInfo( + description: [ + "Flux is the largest decentralized cloud, based on a global network of user-operated nodes.", + "Flux offers a powerful, scalable, and affordable cutting edge technology platform for all.", + "Flux operates servers in SimpleX network to improve its privacy and decentralization." + ], + website: URL(string: "https://runonflux.com")!, + selfhost: (text: "Self-host SimpleX servers on Flux", link: URL(string: "https://home.runonflux.io/apps/marketplace?q=simplex")!), + logo: "flux_logo_symbol", + largeLogo: "flux_logo", + logoDarkMode: "flux_logo_symbol", + largeLogoDarkMode: "flux_logo-light" + ), +] + +struct UsageConditions: Decodable { + var conditionsId: Int64 + var conditionsCommit: String + var notifiedAt: Date? + var createdAt: Date + + static var sampleData = UsageConditions( + conditionsId: 1, + conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c", + notifiedAt: nil, + createdAt: Date.now + ) +} + +enum UsageConditionsAction: Decodable { + case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool) + case accepted(operators: [ServerOperator]) + + var showNotice: Bool { + switch self { + case let .review(_, _, showNotice): showNotice + case .accepted: false + } + } +} + +struct ServerOperatorConditions: Decodable { + var serverOperators: [ServerOperator] + var currentConditions: UsageConditions + var conditionsAction: UsageConditionsAction? + + static var empty = ServerOperatorConditions( + serverOperators: [], + currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now), + conditionsAction: nil + ) +} + +enum ConditionsAcceptance: Equatable, Codable, Hashable { + case accepted(acceptedAt: Date?, autoAccepted: Bool) + // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. + // No deadline indicates it's required to accept conditions for the operator to start using it. + case required(deadline: Date?) + + var conditionsAccepted: Bool { + switch self { + case .accepted: true + case .required: false + } + } + + var usageAllowed: Bool { + switch self { + case .accepted: true + case let .required(deadline): deadline != nil + } + } +} + +struct ServerOperator: Identifiable, Equatable, Codable { + var operatorId: Int64 + var operatorTag: OperatorTag? + var tradeName: String + var legalName: String? + var serverDomains: [String] + var conditionsAcceptance: ConditionsAcceptance + var enabled: Bool + var smpRoles: ServerRoles + var xftpRoles: ServerRoles + + var id: Int64 { operatorId } + + static func == (l: ServerOperator, r: ServerOperator) -> Bool { + l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName && + l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled && + l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles + } + + var legalName_: String { + legalName ?? tradeName + } + + var info: ServerOperatorInfo { + return if let operatorTag = operatorTag { + operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo + } else { + ServerOperator.dummyOperatorInfo + } + } + + static let dummyOperatorInfo = ServerOperatorInfo( + description: ["Default"], + website: URL(string: "https://simplex.chat")!, + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ) + + func logo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.logo : info.logoDarkMode + } + + func largeLogo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode + } + + static var sampleData1 = ServerOperator( + operatorId: 1, + operatorTag: .simplex, + tradeName: "SimpleX Chat", + legalName: "SimpleX Chat Ltd", + serverDomains: ["simplex.im"], + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), + enabled: true, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) +} + +struct ServerRoles: Equatable, Codable { + var storage: Bool + var proxy: Bool +} + +struct UserOperatorServers: Identifiable, Equatable, Codable { + var `operator`: ServerOperator? + var smpServers: [UserServer] + var xftpServers: [UserServer] + + var id: String { + if let op = self.operator { + "\(op.operatorId)" + } else { + "nil operator" + } + } + + var operator_: ServerOperator { + get { + self.operator ?? ServerOperator( + operatorId: 0, + operatorTag: nil, + tradeName: "", + legalName: "", + serverDomains: [], + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), + enabled: false, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) + } + set { `operator` = newValue } + } + + static var sampleData1 = UserOperatorServers( + operator: ServerOperator.sampleData1, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) + + static var sampleDataNilOperator = UserOperatorServers( + operator: nil, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) +} + +enum UserServersError: Decodable { + case noServers(protocol: ServerProtocol, user: UserRef?) + case storageMissing(protocol: ServerProtocol, user: UserRef?) + case proxyMissing(protocol: ServerProtocol, user: UserRef?) + case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + + var globalError: String? { + switch self { + case let .noServers(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .storageMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .proxyMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + default: return nil + } + } + + var globalSMPError: String? { + switch self { + case let .noServers(.smp, user): + let text = NSLocalizedString("No message servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.smp, user): + let text = NSLocalizedString("No servers to receive messages.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.smp, user): + let text = NSLocalizedString("No servers for private message routing.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + var globalXFTPError: String? { + switch self { + case let .noServers(.xftp, user): + let text = NSLocalizedString("No media & file servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.xftp, user): + let text = NSLocalizedString("No servers to send files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.xftp, user): + let text = NSLocalizedString("No servers to receive files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + private func userStr(_ user: UserRef) -> String { + String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName) + } +} + +struct UserServer: Identifiable, Equatable, Codable, Hashable { + var serverId: Int64? + var server: String + var preset: Bool + var tested: Bool? + var enabled: Bool + var deleted: Bool + var createdAt = Date() + + static func == (l: UserServer, r: UserServer) -> Bool { + l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested && + l.enabled == r.enabled && l.deleted == r.deleted + } + + var id: String { "\(server) \(createdAt)" } + + static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false) + + var isEmpty: Bool { + server.trimmingCharacters(in: .whitespaces) == "" + } + + struct SampleData { + var preset: UserServer + var custom: UserServer + var untested: UserServer + var xftpPreset: UserServer + } + + static var sampleData = SampleData( + preset: UserServer( + serverId: 1, + server: "smp://abcd@smp8.simplex.im", + preset: true, + tested: true, + enabled: true, + deleted: false + ), + custom: UserServer( + serverId: 2, + server: "smp://abcd@smp9.simplex.im", + preset: false, + tested: false, + enabled: false, + deleted: false + ), + untested: UserServer( + serverId: 3, + server: "smp://abcd@smp10.simplex.im", + preset: false, + tested: nil, + enabled: true, + deleted: false + ), + xftpPreset: UserServer( + serverId: 4, + server: "xftp://abcd@xftp8.simplex.im", + preset: true, + tested: true, + enabled: true, + deleted: false + ) + ) + + enum CodingKeys: CodingKey { + case serverId + case server + case preset + case tested + case enabled + case deleted + } +} + +enum ProtocolTestStep: String, Decodable, Equatable { + case connect + case disconnect + case createQueue + case secureQueue + case deleteQueue + case createFile + case uploadFile + case downloadFile + case compareFile + case deleteFile + + var text: String { + switch self { + case .connect: return NSLocalizedString("Connect", comment: "server test step") + case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step") + case .createQueue: return NSLocalizedString("Create queue", comment: "server test step") + case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step") + case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step") + case .createFile: return NSLocalizedString("Create file", comment: "server test step") + case .uploadFile: return NSLocalizedString("Upload file", comment: "server test step") + case .downloadFile: return NSLocalizedString("Download file", comment: "server test step") + case .compareFile: return NSLocalizedString("Compare file", comment: "server test step") + case .deleteFile: return NSLocalizedString("Delete file", comment: "server test step") + } + } +} + +struct ProtocolTestFailure: Decodable, Error, Equatable { + var testStep: ProtocolTestStep + var testError: AgentErrorType + + static func == (l: ProtocolTestFailure, r: ProtocolTestFailure) -> Bool { + l.testStep == r.testStep + } + + var localizedDescription: String { + let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "server test failure"), testStep.text) + switch testError { + case .SMP(_, .AUTH): + return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error") + case .XFTP(.AUTH): + return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error") + case .BROKER(_, .NETWORK): + return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error") + default: + return err + } + } +} + +struct MigrationFileLinkData: Codable { + let networkConfig: NetworkConfig? + + struct NetworkConfig: Codable { + let socksProxy: String? + let networkProxy: NetworkProxy? + let hostMode: HostMode? + let requiredHostMode: Bool? + + func transformToPlatformSupported() -> NetworkConfig { + return if let hostMode, let requiredHostMode { + NetworkConfig( + socksProxy: nil, + networkProxy: nil, + hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode, + requiredHostMode: requiredHostMode + ) + } else { self } + } + } + + func addToLink(link: String) -> String { + "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)" + } + + static func readFromLink(link: String) -> MigrationFileLinkData? { +// standaloneFileInfo(link) + nil + } +} + +struct AppSettings: Codable, Equatable { + var networkConfig: NetCfg? = nil + var networkProxy: NetworkProxy? = nil + var privacyEncryptLocalFiles: Bool? = nil + var privacyAskToApproveRelays: Bool? = nil + var privacyAcceptImages: Bool? = nil + var privacyLinkPreviews: Bool? = nil + var privacyShowChatPreviews: Bool? = nil + var privacySaveLastDraft: Bool? = nil + var privacyProtectScreen: Bool? = nil + var privacyMediaBlurRadius: Int? = nil + var notificationMode: AppSettingsNotificationMode? = nil + var notificationPreviewMode: NotificationPreviewMode? = nil + var webrtcPolicyRelay: Bool? = nil + var webrtcICEServers: [String]? = nil + var confirmRemoteSessions: Bool? = nil + var connectRemoteViaMulticast: Bool? = nil + var connectRemoteViaMulticastAuto: Bool? = nil + var developerTools: Bool? = nil + var confirmDBUpgrades: Bool? = nil + var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil + var iosCallKitEnabled: Bool? = nil + var iosCallKitCallsInRecents: Bool? = nil + var uiProfileImageCornerRadius: Double? = nil + var uiChatItemRoundness: Double? = nil + var uiChatItemTail: Bool? = nil + var uiColorScheme: String? = nil + var uiDarkColorScheme: String? = nil + var uiCurrentThemeIds: [String: String]? = nil + var uiThemes: [ThemeOverrides]? = nil + var oneHandUI: Bool? = nil + var chatBottomBar: Bool? = nil + + func prepareForExport() -> AppSettings { + var empty = AppSettings() + let def = AppSettings.defaults + if networkConfig != def.networkConfig { empty.networkConfig = networkConfig } + if networkProxy != def.networkProxy { empty.networkProxy = networkProxy } + if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } + if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } + if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } + if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } + if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } + if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } + if privacyMediaBlurRadius != def.privacyMediaBlurRadius { empty.privacyMediaBlurRadius = privacyMediaBlurRadius } + if notificationMode != def.notificationMode { empty.notificationMode = notificationMode } + if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode } + if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay } + if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers } + if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions } + if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast } + if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto } + if developerTools != def.developerTools { empty.developerTools = developerTools } + if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades } + if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen } + if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled } + if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } + if uiProfileImageCornerRadius != def.uiProfileImageCornerRadius { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius } + if uiChatItemRoundness != def.uiChatItemRoundness { empty.uiChatItemRoundness = uiChatItemRoundness } + if uiChatItemTail != def.uiChatItemTail { empty.uiChatItemTail = uiChatItemTail } + if uiColorScheme != def.uiColorScheme { empty.uiColorScheme = uiColorScheme } + if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme } + if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds } + if uiThemes != def.uiThemes { empty.uiThemes = uiThemes } + if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } + if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar } + return empty + } + + static var defaults: AppSettings { + AppSettings ( + networkConfig: NetCfg.defaults, + networkProxy: NetworkProxy.def, + privacyEncryptLocalFiles: true, + privacyAskToApproveRelays: true, + privacyAcceptImages: true, + privacyLinkPreviews: true, + privacyShowChatPreviews: true, + privacySaveLastDraft: true, + privacyProtectScreen: false, + privacyMediaBlurRadius: 0, + notificationMode: AppSettingsNotificationMode.instant, + notificationPreviewMode: NotificationPreviewMode.message, + webrtcPolicyRelay: true, + webrtcICEServers: [], + confirmRemoteSessions: false, + connectRemoteViaMulticast: true, + connectRemoteViaMulticastAuto: true, + developerTools: false, + confirmDBUpgrades: false, + androidCallOnLockScreen: AppSettingsLockScreenCalls.show, + iosCallKitEnabled: true, + iosCallKitCallsInRecents: false, + uiProfileImageCornerRadius: 22.5, + uiChatItemRoundness: 0.75, + uiChatItemTail: true, + uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME, + uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName, + uiCurrentThemeIds: nil as [String: String]?, + uiThemes: nil as [ThemeOverrides]?, + oneHandUI: true, + chatBottomBar: true + ) + } +} + +enum AppSettingsNotificationMode: String, Codable { + case off + case periodic + case instant + + func toNotificationsMode() -> NotificationsMode { + switch self { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } + + static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode { + switch mode { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } +} + +//enum NotificationPreviewMode: Codable { +// case hidden +// case contact +// case message +//} + +enum AppSettingsLockScreenCalls: String, Codable { + case disable + case show + case accept +} + +struct UserNetworkInfo: Codable, Equatable { + let networkType: UserNetworkType + let online: Bool +} + +enum UserNetworkType: String, Codable { + case none + case cellular + case wifi + case ethernet + case other + + var text: LocalizedStringKey { + switch self { + case .none: "No network connection" + case .cellular: "Cellular" + case .wifi: "WiFi" + case .ethernet: "Wired ethernet" + case .other: "Other" + } + } +} + +struct RcvMsgInfo: Codable { + var msgId: Int64 + var msgDeliveryId: Int64 + var msgDeliveryStatus: String + var agentMsgId: Int64 + var agentMsgMeta: String +} + +struct ServerQueueInfo: Codable { + var server: String + var rcvId: String + var sndId: String + var ntfId: String? + var status: String + var info: QueueInfo +} + +struct QueueInfo: Codable { + var qiSnd: Bool + var qiNtf: Bool + var qiSub: QSub? + var qiSize: Int + var qiMsg: MsgInfo? +} + +struct QSub: Codable { + var qSubThread: QSubThread + var qDelivered: String? +} + +enum QSubThread: String, Codable { + case noSub + case subPending + case subThread + case prohibitSub +} + +struct MsgInfo: Codable { + var msgId: String + var msgTs: Date + var msgType: MsgType +} + +enum MsgType: String, Codable { + case message + case quota +} + +struct PresentedServersSummary: Codable { + var statsStartedAt: Date + var allUsersSMP: SMPServersSummary + var allUsersXFTP: XFTPServersSummary + var currentUserSMP: SMPServersSummary + var currentUserXFTP: XFTPServersSummary +} + +struct SMPServersSummary: Codable { + var smpTotals: SMPTotals + var currentlyUsedSMPServers: [SMPServerSummary] + var previouslyUsedSMPServers: [SMPServerSummary] + var onlyProxiedSMPServers: [SMPServerSummary] +} + +struct SMPTotals: Codable { + var sessions: ServerSessions + var subs: SMPServerSubs + var stats: AgentSMPServerStatsData +} + +struct SMPServerSummary: Codable, Identifiable { + var smpServer: String + var known: Bool? + var sessions: ServerSessions? + var subs: SMPServerSubs? + var stats: AgentSMPServerStatsData? + + var id: String { smpServer } + + var hasSubs: Bool { subs != nil } + + var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions } + + var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs } +} + +struct ServerSessions: Codable { + var ssConnected: Int + var ssErrors: Int + var ssConnecting: Int + + static var newServerSessions = ServerSessions( + ssConnected: 0, + ssErrors: 0, + ssConnecting: 0 + ) + + var hasSess: Bool { ssConnected > 0 } +} + +struct SMPServerSubs: Codable { + var ssActive: Int + var ssPending: Int + + static var newSMPServerSubs = SMPServerSubs( + ssActive: 0, + ssPending: 0 + ) + + var total: Int { ssActive + ssPending } + + var shareOfActive: Double { + guard total != 0 else { return 0.0 } + return Double(ssActive) / Double(total) + } +} + +struct AgentSMPServerStatsData: Codable { + var _sentDirect: Int + var _sentViaProxy: Int + var _sentProxied: Int + var _sentDirectAttempts: Int + var _sentViaProxyAttempts: Int + var _sentProxiedAttempts: Int + var _sentAuthErrs: Int + var _sentQuotaErrs: Int + var _sentExpiredErrs: Int + var _sentOtherErrs: Int + var _recvMsgs: Int + var _recvDuplicates: Int + var _recvCryptoErrs: Int + var _recvErrs: Int + var _ackMsgs: Int + var _ackAttempts: Int + var _ackNoMsgErrs: Int + var _ackOtherErrs: Int + var _connCreated: Int + var _connSecured: Int + var _connCompleted: Int + var _connDeleted: Int + var _connDelAttempts: Int + var _connDelErrs: Int + var _connSubscribed: Int + var _connSubAttempts: Int + var _connSubIgnored: Int + var _connSubErrs: Int + var _ntfKey: Int + var _ntfKeyAttempts: Int + var _ntfKeyDeleted: Int + var _ntfKeyDeleteAttempts: Int +} + +struct XFTPServersSummary: Codable { + var xftpTotals: XFTPTotals + var currentlyUsedXFTPServers: [XFTPServerSummary] + var previouslyUsedXFTPServers: [XFTPServerSummary] +} + +struct XFTPTotals: Codable { + var sessions: ServerSessions + var stats: AgentXFTPServerStatsData +} + +struct XFTPServerSummary: Codable, Identifiable { + var xftpServer: String + var known: Bool? + var sessions: ServerSessions? + var stats: AgentXFTPServerStatsData? + var rcvInProgress: Bool + var sndInProgress: Bool + var delInProgress: Bool + + var id: String { xftpServer } +} + +struct AgentXFTPServerStatsData: Codable { + var _uploads: Int + var _uploadsSize: Int64 + var _uploadAttempts: Int + var _uploadErrs: Int + var _downloads: Int + var _downloadsSize: Int64 + var _downloadAttempts: Int + var _downloadAuthErrs: Int + var _downloadErrs: Int + var _deletions: Int + var _deleteAttempts: Int + var _deleteErrs: Int +} + +struct AgentNtfServerStatsData: Codable { + var _ntfCreated: Int + var _ntfCreateAttempts: Int + var _ntfChecked: Int + var _ntfCheckAttempts: Int + var _ntfDeleted: Int + var _ntfDelAttempts: Int +} diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 95cebcde10..f8cb022095 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -30,9 +30,18 @@ actor TerminalItems { } } - func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async { + func addCommand(_ start: Date, _ cmd: ChatCommand, _ res: APIResult) async { await add(.cmd(start, cmd)) - await add(.resp(.now, resp)) + await addResult(res) + } + + func addResult(_ res: APIResult) async { + let item: TerminalItem = switch res { + case let .result(r): .res(.now, r) + case let .error(e): .err(.now, e) + case let .invalid(type, json): .bad(.now, type, json) + } + await add(item) } } @@ -43,8 +52,26 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { items.append(item) } +// analogue for SecondaryContextFilter in Kotlin +enum SecondaryItemsModelFilter { + case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) + case msgContentTagContext(contentTag: MsgContentTag) + + func descr() -> String { + switch self { + case let .groupChatScopeContext(groupScopeInfo): + return "groupChatScopeContext \(groupScopeInfo.toChatScope())" + case let .msgContentTagContext(contentTag): + return "msgContentTagContext \(contentTag.rawValue)" + } + } +} + +// analogue for ChatsContext in Kotlin class ItemsModel: ObservableObject { - static let shared = ItemsModel() + static let shared = ItemsModel(secondaryIMFilter: nil) + public var secondaryIMFilter: SecondaryItemsModelFilter? + public var preloadState = PreloadState() private let publisher = ObservableObjectPublisher() private var bag = Set() var reversedChatItems: [ChatItem] = [] { @@ -53,51 +80,103 @@ class ItemsModel: ObservableObject { var itemAdded = false { willSet { publisher.send() } } - + + let chatState = ActiveChatState() + // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @Published var isLoading = false - @Published var showLoadingProgress = false + @Published var showLoadingProgress: ChatId? = nil - init() { + private var navigationTimeoutTask: Task? = nil + private var loadChatTask: Task? = nil + + var lastItemsLoaded: Bool { + chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id + } + + init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) { + self.secondaryIMFilter = secondaryIMFilter publisher .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) .sink { self.objectWillChange.send() } .store(in: &bag) } + static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) { + let im = ItemsModel(secondaryIMFilter: chatFilter) + ChatModel.shared.secondaryIM = im + im.loadOpenChat(chatId, willNavigate: willNavigate) + } + func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { - let navigationTimeout = Task { + navigationTimeoutTask?.cancel() + loadChatTask?.cancel() + navigationTimeoutTask = Task { do { try await Task.sleep(nanoseconds: 250_000000) await MainActor.run { - willNavigate() ChatModel.shared.chatId = chatId + willNavigate() } } catch {} } - let progressTimeout = Task { - do { - try await Task.sleep(nanoseconds: 1500_000000) - await MainActor.run { showLoadingProgress = true } - } catch {} - } - Task { - if let chat = ChatModel.shared.getChat(chatId) { - await MainActor.run { self.isLoading = true } -// try? await Task.sleep(nanoseconds: 5000_000000) - await loadChat(chat: chat) - navigationTimeout.cancel() - progressTimeout.cancel() + loadChatTask = Task { + await MainActor.run { self.isLoading = true } +// try? await Task.sleep(nanoseconds: 1000_000000) + await loadChat(chatId: chatId, im: self) + if !Task.isCancelled { await MainActor.run { self.isLoading = false - self.showLoadingProgress = false - willNavigate() - ChatModel.shared.chatId = chatId + self.showLoadingProgress = nil } } } } + + func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { + navigationTimeoutTask?.cancel() + loadChatTask?.cancel() + loadChatTask = Task { + // try? await Task.sleep(nanoseconds: 1000_000000) + await loadChat(chatId: chatId, im: self, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) + if !Task.isCancelled { + await MainActor.run { + if openAroundItemId == nil { + ChatModel.shared.chatId = chatId + } + } + } + } + } + + public var contentTag: MsgContentTag? { + switch secondaryIMFilter { + case nil: nil + case .groupChatScopeContext: nil + case let .msgContentTagContext(contentTag): contentTag + } + } + + public var groupScopeInfo: GroupChatScopeInfo? { + switch secondaryIMFilter { + case nil: nil + case let .groupChatScopeContext(scopeInfo): scopeInfo + case .msgContentTagContext: nil + } + } +} + +class PreloadState { + var prevFirstVisible: Int64 = Int64.min + var prevItemsCount: Int = 0 + var preloading: Bool = false + + func clear() { + prevFirstVisible = Int64.min + prevItemsCount = 0 + preloading = false + } } class ChatTagsModel: ObservableObject { @@ -124,11 +203,9 @@ class ChatTagsModel: ObservableObject { } } } - if case let .presetTag(tag) = tm.activeFilter, (newPresetTags[tag] ?? 0) == 0 { - activeFilter = nil - } presetTags = newPresetTags unreadTags = newUnreadTags + clearActiveChatFilterIfNeeded() } func updateChatFavorite(favorite: Bool, wasFavorite: Bool) { @@ -137,9 +214,7 @@ class ChatTagsModel: ObservableObject { presetTags[.favorites] = (count ?? 0) + 1 } else if !favorite && wasFavorite, let count { presetTags[.favorites] = max(0, count - 1) - if case .presetTag(.favorites) = activeFilter, (presetTags[.favorites] ?? 0) == 0 { - activeFilter = nil - } + clearActiveChatFilterIfNeeded() } } @@ -163,6 +238,7 @@ class ChatTagsModel: ObservableObject { } } } + clearActiveChatFilterIfNeeded() } func markChatTagRead(_ chat: Chat) -> Void { @@ -193,7 +269,17 @@ class ChatTagsModel: ObservableObject { func changeGroupReportsTag(_ by: Int = 0) { if by == 0 { return } - presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by + presetTags[.groupReports] = max(0, (presetTags[.groupReports] ?? 0) + by) + clearActiveChatFilterIfNeeded() + } + + func clearActiveChatFilterIfNeeded() { + let clear = switch activeFilter { + case let .presetTag(tag): (presetTags[tag] ?? 0) == 0 + case let .userTag(tag): !userTags.contains(tag) + case .unread, nil: false + } + if clear { activeFilter = nil } } } @@ -253,7 +339,7 @@ final class ChatModel: ObservableObject { @Published var deletedChats: Set = [] // current chat @Published var chatId: String? - var chatItemStatuses: Dictionary = [:] + @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @@ -267,6 +353,7 @@ final class ChatModel: ObservableObject { @Published var deviceToken: DeviceToken? @Published var savedToken: DeviceToken? @Published var tokenRegistered = false + @Published var reRegisterTknStatus: NtfTknStatus? = nil @Published var tokenStatus: NtfTknStatus? @Published var notificationMode = NotificationsMode.off @Published var notificationServer: String? @@ -301,6 +388,10 @@ final class ChatModel: ObservableObject { let im = ItemsModel.shared + // ItemsModel for secondary chat view (such as support scope chat), as opposed to ItemsModel.shared used for primary chat + @Published var secondaryIM: ItemsModel? = nil + @Published var secondaryPendingInviteeChatOpened = false + static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } let ntfEnableLocal = true @@ -358,7 +449,7 @@ final class ChatModel: ObservableObject { func getGroupChat(_ groupId: Int64) -> Chat? { chats.first { chat in - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { return groupInfo.groupId == groupId } else { return false @@ -411,7 +502,11 @@ final class ChatModel: ObservableObject { func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { - chats[i].chatInfo = cInfo + if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { + chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil) + } else { + chats[i].chatInfo = cInfo + } chats[i].created = Date.now } } @@ -433,7 +528,7 @@ final class ChatModel: ObservableObject { } func updateGroup(_ groupInfo: GroupInfo) { - updateChat(.group(groupInfo: groupInfo)) + updateChat(.group(groupInfo: groupInfo, groupChatScope: nil)) } private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { @@ -476,76 +571,112 @@ final class ChatModel: ObservableObject { // } func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + // updates membersRequireAttention + updateChatInfo(cInfo) // mark chat non deleted if case let .direct(contact) = cInfo, contact.chatDeleted { var updatedContact = contact updatedContact.chatDeleted = false updateContact(updatedContact) } - // update previews + // update chat list if let i = getChatIndex(cInfo.id) { - chats[i].chatItems = switch cInfo { - case .group: - if let currentPreviewItem = chats[i].chatItems.first { - if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { - [cItem] + // update preview + if cInfo.groupChatScope() == nil || cInfo.groupInfo?.membership.memberPending ?? false { + chats[i].chatItems = switch cInfo { + case .group: + if let currentPreviewItem = chats[i].chatItems.first { + if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { + [cItem] + } else { + [currentPreviewItem] + } } else { - [currentPreviewItem] + [cItem] } - } else { + default: [cItem] } - default: - [cItem] - } - if case .rcvNew = cItem.meta.itemStatus { - unreadCollector.changeUnreadCounter(cInfo.id, by: 1) + if case .rcvNew = cItem.meta.itemStatus { + unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) + } } + // pop chat popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) } else { - addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + if cInfo.groupChatScope() == nil { + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + } else { + addChat(Chat(chatInfo: cInfo, chatItems: [])) + } } - // add to current chat - if chatId == cInfo.id { - _ = _upsertChatItem(cInfo, cItem) + // add to current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + _ = _upsertChatItem(ciIM, cInfo, cItem) + } + } + + func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? { + let cInfoScope = cInfo.groupChatScope() + if let cInfoScope = cInfoScope { + switch cInfoScope { + case .memberSupport: + switch secondaryIM?.secondaryIMFilter { + case .none: + return nil + case let .groupChatScopeContext(groupScopeInfo): + return (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil + case let .msgContentTagContext(contentTag): + return (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil + } + } + } else { + return cInfo.id == chatId ? im : nil } } func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { - // update previews - var res: Bool - if let chat = getChat(cInfo.id) { - if let pItem = chat.chatItems.last { - if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + // update chat list + var itemAdded: Bool = false + if cInfo.groupChatScope() == nil { + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last { + if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + chat.chatItems = [cItem] + } + } else { chat.chatItems = [cItem] } } else { - chat.chatItems = [cItem] + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + itemAdded = true + } + if cItem.isDeletedContent || cItem.meta.itemDeleted != nil { + VoiceItemState.stopVoiceInChatView(cInfo, cItem) } - res = false - } else { - addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) - res = true } - if cItem.isDeletedContent || cItem.meta.itemDeleted != nil { - VoiceItemState.stopVoiceInChatView(cInfo, cItem) + // update current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + itemAdded = _upsertChatItem(ciIM, cInfo, cItem) } - // update current chat - return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res + return itemAdded } - private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { - if let i = getChatItemIndex(cItem) { - _updateChatItem(at: i, with: cItem) - ChatItemDummyModel.shared.sendUpdate() + private func _upsertChatItem(_ ciIM: ItemsModel, _ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { + if let i = getChatItemIndex(ciIM, cItem) { + let oldStatus = ciIM.reversedChatItems[i].meta.itemStatus + let newStatus = cItem.meta.itemStatus + var ci = cItem + if shouldKeepOldSndCIStatus(oldStatus: oldStatus, newStatus: newStatus) { + ci.meta.itemStatus = oldStatus + } + _updateChatItem(ciIM: ciIM, at: i, with: ci) + ChatItemDummyModel.shared.sendUpdate() // TODO [knocking] review what's this return false } else { - var ci = cItem - if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { - ci.meta.itemStatus = status - } - im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) - im.itemAdded = true + ciIM.reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + ciIM.chatState.itemAdded((cItem.id, cItem.isRcvNew), hasLiveDummy ? 1 : 0) + ciIM.itemAdded = true ChatItemDummyModel.shared.sendUpdate() return true } @@ -559,45 +690,87 @@ final class ChatModel: ObservableObject { } func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { - if chatId == cInfo.id, let i = getChatItemIndex(cItem) { + if let ciIM = getCIItemsModel(cInfo, cItem), + let i = getChatItemIndex(ciIM, cItem) { withConditionalAnimation { - _updateChatItem(at: i, with: cItem) + _updateChatItem(ciIM: ciIM, at: i, with: cItem) } - } else if let status = status { - chatItemStatuses.updateValue(status, forKey: cItem.id) } } - private func _updateChatItem(at i: Int, with cItem: ChatItem) { - im.reversedChatItems[i] = cItem - im.reversedChatItems[i].viewTimestamp = .now + private func _updateChatItem(ciIM: ItemsModel, at i: Int, with cItem: ChatItem) { + ciIM.reversedChatItems[i] = cItem + ciIM.reversedChatItems[i].viewTimestamp = .now } - func getChatItemIndex(_ cItem: ChatItem) -> Int? { - im.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) + func getChatItemIndex(_ ciIM: ItemsModel, _ cItem: ChatItem) -> Int? { + ciIM.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) } func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { - if cItem.isRcvNew { - unreadCollector.changeUnreadCounter(cInfo.id, by: -1) - } - // update previews - if let chat = getChat(cInfo.id) { - if let pItem = chat.chatItems.last, pItem.id == cItem.id { - chat.chatItems = [ChatItem.deletedItemDummy()] + // update chat list + if cInfo.groupChatScope() == nil { + if cItem.isRcvNew { + unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) + } + // update previews + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last, pItem.id == cItem.id { + chat.chatItems = [ChatItem.deletedItemDummy()] + } } } - // remove from current chat - if chatId == cInfo.id { - if let i = getChatItemIndex(cItem) { - _ = withAnimation { - im.reversedChatItems.remove(at: i) + // remove from current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + if let i = getChatItemIndex(ciIM, cItem) { + withAnimation { + let item = ciIM.reversedChatItems.remove(at: i) + ciIM.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) } } } VoiceItemState.stopVoiceInChatView(cInfo, cItem) } + func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) { + // this should not happen, only another member can "remove" user, user can only "leave" (another event). + if byMember.groupMemberId == groupInfo.membership.groupMemberId { + logger.debug("exiting removeMemberItems") + return + } + if chatId == groupInfo.id { + for i in 0.. 0, + let updatedItem = removedUpdatedItem(chat.chatItems[0]) { + chat.chatItems = [updatedItem] + } + + func removedUpdatedItem(_ item: ChatItem) -> ChatItem? { + let newContent: CIContent + if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId { + newContent = .sndModerated + } else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId { + newContent = .rcvModerated + } else { + return nil + } + var updatedItem = item + updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember) + if groupInfo.fullGroupPreferences.fullDelete.on { + updatedItem.content = newContent + } + if item.isActiveReport { + decreaseGroupReportsCounter(groupInfo.id) + } + return updatedItem + } + } + func nextChatItemData(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } if previous { @@ -640,6 +813,7 @@ final class ChatModel: ObservableObject { let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { im.reversedChatItems.insert(cItem, at: 0) + im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0) im.itemAdded = true } return cItem @@ -659,63 +833,23 @@ final class ChatModel: ObservableObject { im.reversedChatItems.first?.isLiveDummy == true } - func markChatItemsRead(_ cInfo: ChatInfo) { + func markAllChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in - self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) - self.updateFloatingButtons(unreadCount: 0) + self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() } // update current chat if chatId == cInfo.id { - markCurrentChatRead() - } - } - - private func markCurrentChatRead(fromIndex i: Int = 0) { - var j = i - while j < im.reversedChatItems.count { - markChatItemRead_(j) - j += 1 - } - } - - private func updateFloatingButtons(unreadCount: Int) { - let fbm = ChatView.FloatingButtonModel.shared - fbm.totalUnread = unreadCount - fbm.objectWillChange.send() - } - - func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { - if let cItem = aboveItem { - if chatId == cInfo.id, let i = getChatItemIndex(cItem) { - markCurrentChatRead(fromIndex: i) - _updateChat(cInfo.id) { chat in - var unreadBelow = 0 - var j = i - 1 - while j >= 0 { - if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus { - unreadBelow += 1 - } - j -= 1 - } - // update preview - let markedCount = chat.chatStats.unreadCount - unreadBelow - if markedCount > 0 { - let wasUnread = chat.unreadTag - chat.chatStats.unreadCount -= markedCount - ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) - self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) - self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount) - } - } + var i = 0 + while i < im.reversedChatItems.count { + markChatItemRead_(chatIM, i) + i += 1 } - } else { - markChatItemsRead(cInfo) + im.chatState.itemsRead(nil, im.reversedChatItems.reversed()) } } - func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in let wasUnread = chat.unreadTag @@ -727,7 +861,7 @@ final class ChatModel: ObservableObject { func clearChat(_ cInfo: ChatInfo) { // clear preview if let chat = getChat(cInfo.id) { - self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) chat.chatItems = [] ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() @@ -735,20 +869,28 @@ final class ChatModel: ObservableObject { } // clear current chat if chatId == cInfo.id { - chatItemStatuses = [:] im.reversedChatItems = [] + im.chatState.clear() } } - func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) { + func markChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { if self.chatId == cInfo.id { - for itemId in itemIds { - if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { - markChatItemRead_(i) + var unreadItemIds: Set = [] + var i = 0 + var ids = Set(itemIds) + while i < chatIM.reversedChatItems.count && !ids.isEmpty { + let item = chatIM.reversedChatItems[i] + if ids.contains(item.id) && item.isRcvNew { + markChatItemRead_(chatIM, i) + unreadItemIds.insert(item.id) + ids.remove(item.id) } + i += 1 } + chatIM.chatState.itemsRead(unreadItemIds, chatIM.reversedChatItems.reversed()) } - self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count) + self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) } private let unreadCollector = UnreadCollector() @@ -756,16 +898,16 @@ final class ChatModel: ObservableObject { class UnreadCollector { private let subject = PassthroughSubject() private var bag = Set() - private var unreadCounts: [ChatId: Int] = [:] + private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:] init() { subject .debounce(for: 1, scheduler: DispatchQueue.main) .sink { let m = ChatModel.shared - for (chatId, count) in self.unreadCounts { - if let i = m.getChatIndex(chatId) { - m.changeUnreadCounter(i, by: count) + for (chatId, (unread, mentions)) in self.unreadCounts { + if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) { + m.changeUnreadCounter(i, by: unread, unreadMentions: mentions) } } self.unreadCounts = [:] @@ -773,11 +915,9 @@ final class ChatModel: ObservableObject { .store(in: &bag) } - func changeUnreadCounter(_ chatId: ChatId, by count: Int) { - if chatId == ChatModel.shared.chatId { - ChatView.FloatingButtonModel.shared.totalUnread += count - } - self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count + func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) { + let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0) + self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions) subject.send() } } @@ -844,20 +984,22 @@ final class ChatModel: ObservableObject { } } - private func markChatItemRead_(_ i: Int) { - let meta = im.reversedChatItems[i].meta + private func markChatItemRead_(_ chatIM: ItemsModel, _ i: Int) { + let meta = chatIM.reversedChatItems[i].meta if case .rcvNew = meta.itemStatus { - im.reversedChatItems[i].meta.itemStatus = .rcvRead - im.reversedChatItems[i].viewTimestamp = .now + chatIM.reversedChatItems[i].meta.itemStatus = .rcvRead + chatIM.reversedChatItems[i].viewTimestamp = .now if meta.itemLive != true, let ttl = meta.itemTimed?.ttl { - im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) + chatIM.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) } } } - func changeUnreadCounter(_ chatIndex: Int, by count: Int) { + func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) { let wasUnread = chats[chatIndex].unreadTag - chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count + let stats = chats[chatIndex].chatStats + chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count + chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) changeUnreadCounter(user: currentUser!, by: count) } @@ -866,6 +1008,13 @@ final class ChatModel: ObservableObject { changeUnreadCounter(user: user, by: 1) } + func decreaseUnreadCounter(user: any UserLike, chat: Chat) { + let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions + ? chat.chatStats.unreadMentions + : chat.chatStats.unreadCount + decreaseUnreadCounter(user: user, by: by) + } + func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { changeUnreadCounter(user: user, by: -by) } @@ -878,8 +1027,20 @@ final class ChatModel: ObservableObject { } func totalUnreadCountForAllUsers() -> Int { - chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + - users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) + var unread: Int = 0 + for chat in chats { + switch chat.chatInfo.chatSettings?.enableNtfs { + case .all: unread += chat.chatStats.unreadCount + case .mentions: unread += chat.chatStats.unreadMentions + default: () + } + } + for u in users { + if !u.user.activeUser { + unread += u.unreadCount + } + } + return unread } func increaseGroupReportsCounter(_ chatId: ChatId) { @@ -887,7 +1048,7 @@ final class ChatModel: ObservableObject { } func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) { - changeGroupReportsCounter(chatId, -1) + changeGroupReportsCounter(chatId, -by) } private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) { @@ -908,7 +1069,7 @@ final class ChatModel: ObservableObject { var count = 0 var ns: [String] = [] if let ciCategory = chatItem.mergeCategory, - var i = getChatItemIndex(chatItem) { + var i = getChatItemIndex(im, chatItem) { // TODO [knocking] review: use getCIItemsModel? while i < im.reversedChatItems.count { let ci = im.reversedChatItems[i] if ci.mergeCategory != ciCategory { break } @@ -924,7 +1085,7 @@ final class ChatModel: ObservableObject { // returns the index of the passed item and the next item (it has smaller index) func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { - if let i = getChatItemIndex(ci) { + if let i = getChatItemIndex(im, ci) { // TODO [knocking] review: use getCIItemsModel? (i, i > 0 ? im.reversedChatItems[i - 1] : nil) } else { (nil, nil) @@ -948,12 +1109,17 @@ final class ChatModel: ObservableObject { // returns the previous member in the same merge group and the count of members in this group func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange) -> (GroupMember?, Int) { + let items = im.reversedChatItems var prevMember: GroupMember? = nil var memberIds: Set = [] for i in range { - if case let .groupRcv(m) = im.reversedChatItems[i].chatDir { - if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } - memberIds.insert(m.groupMemberId) + if i < items.count { + if case let .groupRcv(m) = items[i].chatDir { + if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } + memberIds.insert(m.groupMemberId) + } + } else { + logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))") } } return (prevMember, memberIds.count) @@ -1030,7 +1196,7 @@ final class ChatModel: ObservableObject { func removeWallpaperFilesFromChat(_ chat: Chat) { if case let .direct(contact) = chat.chatInfo { removeWallpaperFilesFromTheme(contact.uiThemes) - } else if case let .group(groupInfo) = chat.chatInfo { + } else if case let .group(groupInfo, _) = chat.chatInfo { removeWallpaperFilesFromTheme(groupInfo.uiThemes) } } @@ -1082,35 +1248,30 @@ final class Chat: ObservableObject, Identifiable, ChatLike { ) } - var userCanSend: Bool { - switch chatInfo { - case .direct: return true - case let .group(groupInfo): - let m = groupInfo.membership - return m.memberActive && m.memberRole >= .member - case .local: - return true - default: return false - } - } - - var userIsObserver: Bool { - switch chatInfo { - case let .group(groupInfo): - let m = groupInfo.membership - return m.memberActive && m.memberRole == .observer - default: return false - } - } - var unreadTag: Bool { - chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + switch chatInfo.chatSettings?.enableNtfs { + case .all: chatStats.unreadChat || chatStats.unreadCount > 0 + case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0 + default: chatStats.unreadChat + } } var id: ChatId { get { chatInfo.id } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } + var supportUnreadCount: Int { + switch chatInfo { + case let .group(groupInfo, _): + if groupInfo.canModerate { + return groupInfo.membersRequireAttention + } else { + return groupInfo.membership.supportChat?.unread ?? 0 + } + default: return 0 + } + } + public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 6c33031eeb..da55bd90d0 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -248,7 +248,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") - if cInfo.ntfsEnabled { + if cInfo.ntfsEnabled(chatItem: cItem) { addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2380f79d59..4f9d75bada 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -11,7 +11,7 @@ import UIKit import Dispatch import BackgroundTasks import SwiftUI -import SimpleXChat +@preconcurrency import SimpleXChat private var chatController: chat_ctrl? @@ -19,32 +19,34 @@ private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) - case resp(Date, ChatResponse) + case res(Date, ChatAPIResult) + case err(Date, ChatError) + case bad(Date, String, Data?) var id: Date { - get { - switch self { - case let .cmd(id, _): return id - case let .resp(id, _): return id - } + switch self { + case let .cmd(d, _): d + case let .res(d, _): d + case let .err(d, _): d + case let .bad(d, _, _): d } } var label: String { - get { - switch self { - case let .cmd(_, cmd): return "> \(cmd.cmdString.prefix(30))" - case let .resp(_, resp): return "< \(resp.responseType)" - } + switch self { + case let .cmd(_, cmd): "> \(cmd.cmdString.prefix(30))" + case let .res(_, res): "< \(res.responseType)" + case let .err(_, err): "< error \(err.errorType)" + case let .bad(_, type, _): "< * \(type)" } } var details: String { - get { - switch self { - case let .cmd(_, cmd): return cmd.cmdString - case let .resp(_, resp): return resp.details - } + switch self { + case let .cmd(_, cmd): cmd.cmdString + case let .res(_, res): res.details + case let .err(_, err): String(describing: err) + case let .bad(_, _, json): dataToString(json) } } } @@ -86,18 +88,24 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } -func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse { +@inline(__always) +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R { + let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) + return try apiResult(res) +} + +func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) -> APIResult { if log { logger.debug("chatSendCmd \(cmd.cmdType)") } let start = Date.now - let resp = bgTask + let resp: APIResult = bgTask ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) } : sendSimpleXCmd(cmd, ctrl) if log { logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") - if case let .response(_, json) = resp { - logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") + if case let .invalid(_, json) = resp { + logger.debug("chatSendCmd \(cmd.cmdType) response: \(dataToString(json))") } Task { await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp) @@ -106,35 +114,51 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = return resp } -func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse { +@inline(__always) +func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R { + let res: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) + return try apiResult(res) +} + +@inline(__always) +func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async -> APIResult { await withCheckedContinuation { cont in - cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log)) + cont.resume(returning: chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)) } } -func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? { +@inline(__always) +func apiResult(_ res: APIResult) throws -> R { + switch res { + case let .result(r): return r + case let .error(e): throw e + case let .invalid(type, _): throw ChatError.unexpectedResult(type: type) + } +} + +func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult? { await withCheckedContinuation { cont in - _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in - let resp = recvSimpleXMsg(ctrl) - cont.resume(returning: resp) - return resp + _ = withBGTask(bgDelay: msgDelay) { () -> APIResult? in + let evt: APIResult? = recvSimpleXMsg(ctrl) + cont.resume(returning: evt) + return evt } } } func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { - let r = chatSendCmdSync(.showActiveUser, ctrl) + let r: APIResult = chatApiSendCmdSync(.showActiveUser, ctrl: ctrl) switch r { - case let .activeUser(user): return user - case .chatCmdError(_, .error(.noActiveUser)): return nil - default: throw r + case let .result(.activeUser(user)): return user + case .error(.error(.noActiveUser)): return nil + default: throw r.unexpected } } func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { - let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl) + let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl: ctrl) if case let .activeUser(user) = r { return user } - throw r + throw r.unexpected } func listUsers() throws -> [UserInfo] { @@ -145,41 +169,35 @@ func listUsersAsync() async throws -> [UserInfo] { return try listUsersResponse(await chatSendCmd(.listUsers)) } -private func listUsersResponse(_ r: ChatResponse) throws -> [UserInfo] { +private func listUsersResponse(_ r: ChatResponse0) throws -> [UserInfo] { if case let .usersList(users) = r { return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } } - throw r + throw r.unexpected } func apiSetActiveUser(_ userId: Int64, viewPwd: String?) throws -> User { - let r = chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) if case let .activeUser(user) = r { return user } - throw r + throw r.unexpected } func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> User { - let r = await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) + let r: ChatResponse0 = try await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) if case let .activeUser(user) = r { return user } - throw r + throw r.unexpected } func apiSetAllContactReceipts(enable: Bool) async throws { - let r = await chatSendCmd(.setAllContactReceipts(enable: enable)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.setAllContactReceipts(enable: enable)) } func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws { - let r = await chatSendCmd(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) } func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws { - let r = await chatSendCmd(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) } func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User { @@ -199,90 +217,88 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User { } func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User { - let r = await chatSendCmd(cmd) + let r: ChatResponse1 = try await chatSendCmd(cmd) if case let .userPrivacy(_, updatedUser) = r { return updatedUser } - throw r + throw r.unexpected } func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws { - let r = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd)) } func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool { - let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl) + let r: ChatResponse0 = try chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl: ctrl) switch r { case .chatStarted: return true case .chatRunning: return false - default: throw r + default: throw r.unexpected } } func apiCheckChatRunning() throws -> Bool { - let r = chatSendCmdSync(.checkChatRunning) + let r: ChatResponse0 = try chatSendCmdSync(.checkChatRunning) switch r { case .chatRunning: return true case .chatStopped: return false - default: throw r + default: throw r.unexpected } } func apiStopChat() async throws { - let r = await chatSendCmd(.apiStopChat) + let r: ChatResponse0 = try await chatSendCmd(.apiStopChat) switch r { case .chatStopped: return - default: throw r + default: throw r.unexpected } } func apiActivateChat() { chatReopenStore() - let r = chatSendCmdSync(.apiActivateChat(restoreChat: true)) - if case .cmdOk = r { return } - logger.error("apiActivateChat error: \(String(describing: r))") + do { + try sendCommandOkRespSync(.apiActivateChat(restoreChat: true)) + } catch { + logger.error("apiActivateChat error: \(responseError(error))") + } } func apiSuspendChat(timeoutMicroseconds: Int) { - let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) - if case .cmdOk = r { return } - logger.error("apiSuspendChat error: \(String(describing: r))") + do { + try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + } catch { + logger.error("apiSuspendChat error: \(responseError(error))") + } } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { - let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl) + let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl) if case .cmdOk = r { return } - throw r + throw r.unexpected } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) - if case .cmdOk = r { return } - throw r + try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable)) } func apiSaveAppSettings(settings: AppSettings) throws { - let r = chatSendCmdSync(.apiSaveSettings(settings: settings)) - if case .cmdOk = r { return } - throw r + try sendCommandOkRespSync(.apiSaveSettings(settings: settings)) } func apiGetAppSettings(settings: AppSettings) throws -> AppSettings { - let r = chatSendCmdSync(.apiGetSettings(settings: settings)) + let r: ChatResponse2 = try chatSendCmdSync(.apiGetSettings(settings: settings)) if case let .appSettings(settings) = r { return settings } - throw r + throw r.unexpected } func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { - let r = await chatSendCmd(.apiExportArchive(config: config)) + let r: ChatResponse2 = try await chatSendCmd(.apiExportArchive(config: config)) if case let .archiveExported(archiveErrors) = r { return archiveErrors } - throw r + throw r.unexpected } func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { - let r = await chatSendCmd(.apiImportArchive(config: config)) + let r: ChatResponse2 = try await chatSendCmd(.apiImportArchive(config: config)) if case let .archiveImported(archiveErrors) = r { return archiveErrors } - throw r + throw r.unexpected } func apiDeleteStorage() async throws { @@ -293,8 +309,8 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey))) } -func testStorageEncryption(key: String, _ ctrl: chat_ctrl? = nil) async throws { - try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl) +func testStorageEncryption(key: String, ctrl: chat_ctrl? = nil) async throws { + try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl: ctrl) } func apiGetChats() throws -> [ChatData] { @@ -307,92 +323,92 @@ func apiGetChatsAsync() async throws -> [ChatData] { return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId))) } -private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] { +private func apiChatsResponse(_ r: ChatResponse0) throws -> [ChatData] { if case let .apiChats(_, chats) = r { return chats } - throw r + throw r.unexpected } func apiGetChatTags() throws -> [ChatTag] { let userId = try currentUserId("apiGetChatTags") - let r = chatSendCmdSync(.apiGetChatTags(userId: userId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiGetChatTags(userId: userId)) if case let .chatTags(_, tags) = r { return tags } - throw r + throw r.unexpected } func apiGetChatTagsAsync() async throws -> [ChatTag] { let userId = try currentUserId("apiGetChatTags") - let r = await chatSendCmd(.apiGetChatTags(userId: userId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetChatTags(userId: userId)) if case let .chatTags(_, tags) = r { return tags } - throw r + throw r.unexpected } let loadItemsPerPage = 50 -func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat { - let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search)) - if case let .apiChat(_, chat) = r { return Chat.init(chat) } - throw r +func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { + let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search)) + if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) } + throw r.unexpected } -func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { - let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) - if case let .apiChat(_, chat) = r { return chat.chatItems } - throw r +func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async { + await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems) } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async { - do { - let cInfo = chat.chatInfo - let m = ChatModel.shared - let im = ItemsModel.shared - m.chatItemStatuses = [:] +func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { + await MainActor.run { if clearItems { - await MainActor.run { im.reversedChatItems = [] } + im.reversedChatItems = [] + im.chatState.clear() } - let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) - await MainActor.run { - im.reversedChatItems = chat.chatItems.reversed() - m.updateChatInfo(chat.chatInfo) - if (replaceChat) { - m.replaceChat(chat.chatInfo.id, chat) - } - } - } catch let error { - logger.error("loadChat error: \(responseError(error))") } + await apiLoadMessages( + chatId, + im, + ( // pagination + openAroundItemId != nil + ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) + : ( + search == "" + ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage) + ) + ), + search, + openAroundItemId, + { 0...0 } + ) } -func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { - let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId)) +func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo { + let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId)) if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo } - throw r + throw r.unexpected } -func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { - let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds)) +func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { + let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds)) if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) } - throw r + throw r.unexpected } -func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { - let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl) +func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { + let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] { - let r = await chatSendCmd(.apiCreateChatTag(tag: tag)) + let r: ChatResponse0 = try await chatSendCmd(.apiCreateChatTag(tag: tag)) if case let .chatTags(_, userTags) = r { return userTags } - throw r + throw r.unexpected } func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) { - let r = await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds)) + let r: ChatResponse0 = try await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds)) if case let .tagsUpdated(_, userTags, chatTags) = r { return (userTags, chatTags) } - throw r + throw r.unexpected } func apiDeleteChatTag(tagId: Int64) async throws { @@ -407,14 +423,14 @@ func apiReorderChatTags(tagIds: [Int64]) async throws { try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) } -func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? { let chatModel = ChatModel.shared - let r: ChatResponse + let r: APIResult if toChatType == .direct { var cItem: ChatItem? = nil let endTask = beginBGTask({ @@ -424,8 +440,8 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async } } }) - r = await chatSendCmd(cmd, bgTask: false) - if case let .newChatItems(_, aChatItems) = r { + r = await chatApiSendCmd(cmd, bgTask: false) + if case let .result(.newChatItems(_, aChatItems)) = r { let cItems = aChatItems.map { $0.chatItem } if let cItemLast = cItems.last { cItem = cItemLast @@ -436,40 +452,40 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async if let networkErrorAlert = networkErrorAlert(r) { AlertManager.shared.showAlert(networkErrorAlert) } else { - sendMessageErrorAlert(r) + sendMessageErrorAlert(r.unexpected) } endTask() return nil } else { - r = await chatSendCmd(cmd, bgDelay: msgDelay) - if case let .newChatItems(_, aChatItems) = r { + r = await chatApiSendCmd(cmd, bgDelay: msgDelay) + if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } } - sendMessageErrorAlert(r) + sendMessageErrorAlert(r.unexpected) return nil } } func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages)) - if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } - createChatItemsErrorAlert(r) + let r: APIResult = await chatApiSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages)) + if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } } + createChatItemsErrorAlert(r.unexpected) return nil } func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? { - let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) - if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } + let r: APIResult = await chatApiSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) + if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } } logger.error("apiReportMessage error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error creating report", - message: "Error: \(responseError(r))" + message: "Error: \(responseError(r.unexpected))" ) return nil } -private func sendMessageErrorAlert(_ r: ChatResponse) { +private func sendMessageErrorAlert(_ r: ChatError) { logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error sending message", @@ -477,7 +493,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) { ) } -private func createChatItemsErrorAlert(_ r: ChatResponse) { +private func createChatItemsErrorAlert(_ r: ChatError) { logger.error("apiCreateChatItems error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error creating message", @@ -485,42 +501,57 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) { ) } -func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { - let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) - if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } - throw r +func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem { + let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay) + switch r { + case let .chatItemUpdated(_, aChatItem): return aChatItem.chatItem + case let .chatItemNotChanged(_, aChatItem): return aChatItem.chatItem + default: throw r.unexpected + } } -func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem { - let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay) +func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem { + let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay) if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem } - throw r + throw r.unexpected } func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] { let userId = try currentUserId("apiGetReactionMemebers") - let r = await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction )) + let r: ChatResponse1 = try await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction )) if case let .reactionMembers(_, memberReactions) = r { return memberReactions } - throw r + throw r.unexpected } -func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { - let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay) +func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay) if case let .chatItemsDeleted(_, items, _) = r { return items } - throw r + throw r.unexpected } func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] { - let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay) if case let .chatItemsDeleted(_, items, _) = r { return items } - throw r + throw r.unexpected +} + +func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse1 { + let r: ChatResponse1 = try await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay) + if case .groupChatItemsDeleted = r { return r } + throw r.unexpected +} + +func apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay) + if case let .chatItemsDeleted(_, chatItemDeletions, _) = r { return chatItemDeletions } + throw r.unexpected } func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) { - let r = chatSendCmdSync(.apiGetNtfToken) + let r: APIResult = chatApiSendCmdSync(.apiGetNtfToken) switch r { - case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer) - case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil) + case let .result(.ntfToken(token, status, ntfMode, ntfServer)): return (token, status, ntfMode, ntfServer) + case .error(.errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil) default: logger.debug("apiGetNtfToken response: \(String(describing: r))") return (nil, nil, .off, nil) @@ -528,9 +559,9 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String } func apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) async throws -> NtfTknStatus { - let r = await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode)) + let r: ChatResponse2 = try await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode)) if case let .ntfTokenStatus(status) = r { return status } - throw r + throw r.unexpected } func registerToken(token: DeviceToken) { @@ -542,7 +573,12 @@ func registerToken(token: DeviceToken) { Task { do { let status = try await apiRegisterToken(token: token, notificationMode: mode) - await MainActor.run { m.tokenStatus = status } + await MainActor.run { + m.tokenStatus = status + if !status.workingToken { + m.reRegisterTknStatus = status + } + } } catch let error { logger.error("registerToken apiRegisterToken error: \(responseError(error))") } @@ -550,90 +586,129 @@ func registerToken(token: DeviceToken) { } } +func tokenStatusInfo(_ status: NtfTknStatus, register: Bool) -> String { + String.localizedStringWithFormat(NSLocalizedString("Token status: %@.", comment: "token status"), status.text) + + "\n" + status.info(register: register) +} + +func reRegisterToken(token: DeviceToken) { + let m = ChatModel.shared + let mode = m.notificationMode + logger.debug("reRegisterToken \(mode.rawValue)") + Task { + do { + let status = try await apiRegisterToken(token: token, notificationMode: mode) + await MainActor.run { + m.tokenStatus = status + showAlert( + status.workingToken + ? NSLocalizedString("Notifications status", comment: "alert title") + : NSLocalizedString("Notifications error", comment: "alert title"), + message: tokenStatusInfo(status, register: false) + ) + } + } catch let error { + logger.error("reRegisterToken apiRegisterToken error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error registering for notifications", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws { try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code)) } +func apiCheckToken(token: DeviceToken) async throws -> NtfTknStatus { + let r: ChatResponse2 = try await chatSendCmd(.apiCheckToken(token: token)) + if case let .ntfTokenStatus(status) = r { return status } + throw r.unexpected +} + func apiDeleteToken(token: DeviceToken) async throws { try await sendCommandOkResp(.apiDeleteToken(token: token)) } func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> { let userId = try currentUserId("testProtoServer") - let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server)) + let r: ChatResponse0 = try await chatSendCmd(.apiTestProtoServer(userId: userId, server: server)) if case let .serverTestResult(_, _, testFailure) = r { if let t = testFailure { return .failure(t) } return .success(()) } - throw r + throw r.unexpected } func getServerOperators() async throws -> ServerOperatorConditions { - let r = await chatSendCmd(.apiGetServerOperators) + let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("getServerOperators error: \(String(describing: r))") - throw r + throw r.unexpected } func getServerOperatorsSync() throws -> ServerOperatorConditions { - let r = chatSendCmdSync(.apiGetServerOperators) + let r: ChatResponse0 = try chatSendCmdSync(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("getServerOperators error: \(String(describing: r))") - throw r + throw r.unexpected } func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions { - let r = await chatSendCmd(.apiSetServerOperators(operators: operators)) + let r: ChatResponse0 = try await chatSendCmd(.apiSetServerOperators(operators: operators)) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("setServerOperators error: \(String(describing: r))") - throw r + throw r.unexpected } func getUserServers() async throws -> [UserOperatorServers] { let userId = try currentUserId("getUserServers") - let r = await chatSendCmd(.apiGetUserServers(userId: userId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetUserServers(userId: userId)) if case let .userServers(_, userServers) = r { return userServers } logger.error("getUserServers error: \(String(describing: r))") - throw r + throw r.unexpected } func setUserServers(userServers: [UserOperatorServers]) async throws { let userId = try currentUserId("setUserServers") - let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers)) + let r: ChatResponse2 = try await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers)) if case .cmdOk = r { return } logger.error("setUserServers error: \(String(describing: r))") - throw r + throw r.unexpected } func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] { let userId = try currentUserId("validateServers") - let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) + let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) if case let .userServersValidation(_, serverErrors) = r { return serverErrors } logger.error("validateServers error: \(String(describing: r))") - throw r + throw r.unexpected } func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) { - let r = await chatSendCmd(.apiGetUsageConditions) + let r: ChatResponse0 = try await chatSendCmd(.apiGetUsageConditions) if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) } logger.error("getUsageConditions error: \(String(describing: r))") - throw r + throw r.unexpected } func setConditionsNotified(conditionsId: Int64) async throws { - let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId)) + let r: ChatResponse2 = try await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId)) if case .cmdOk = r { return } logger.error("setConditionsNotified error: \(String(describing: r))") - throw r + throw r.unexpected } func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions { - let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)) + let r: ChatResponse0 = try await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("acceptConditions error: \(String(describing: r))") - throw r + throw r.unexpected } func getChatItemTTL() throws -> ChatItemTTL { @@ -646,7 +721,7 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL { return try chatItemTTLResponse(await chatSendCmd(.apiGetChatItemTTL(userId: userId))) } -private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { +private func chatItemTTLResponse(_ r: ChatResponse0) throws -> ChatItemTTL { if case let .chatItemTTL(_, chatItemTTL) = r { if let ttl = chatItemTTL { return ChatItemTTL(ttl) @@ -654,7 +729,7 @@ private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { throw RuntimeError("chatItemTTLResponse: invalid ttl") } } - throw r + throw r.unexpected } func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { @@ -668,21 +743,21 @@ func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async thr } func getNetworkConfig() async throws -> NetCfg? { - let r = await chatSendCmd(.apiGetNetworkConfig) + let r: ChatResponse0 = try await chatSendCmd(.apiGetNetworkConfig) if case let .networkConfig(cfg) = r { return cfg } - throw r + throw r.unexpected } func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws { - let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl) + let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl: ctrl) if case .cmdOk = r { return } - throw r + throw r.unexpected } func apiSetNetworkInfo(_ networkInfo: UserNetworkInfo) throws { - let r = chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo)) + let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo)) if case .cmdOk = r { return } - throw r + throw r.unexpected } func reconnectAllServers() async throws { @@ -703,131 +778,135 @@ func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSett } func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { - let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) + let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } - throw r + throw r.unexpected } func apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) { - let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) } - throw r + throw r.unexpected } func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, ConnectionStats?) { - let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) } - throw r + throw r.unexpected } func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) { - let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId)) + let r: ChatResponse0 = try await chatSendCmd(.apiContactQueueInfo(contactId: contactId)) if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) } - throw r + throw r.unexpected } func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) { - let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId)) if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) } - throw r + throw r.unexpected } func apiSwitchContact(contactId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchContact(contactId: contactId)) if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberSwitchStarted(_, _, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiAbortSwitchContact(_ contactId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId)) if case let .contactSwitchAborted(_, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberSwitchAborted(_, _, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiSyncContactRatchet(_ contactId: Int64, _ force: Bool) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force)) if case let .contactRatchetSyncStarted(_, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiSyncGroupMemberRatchet(_ groupId: Int64, _ groupMemberId: Int64, _ force: Bool) throws -> (GroupMember, ConnectionStats) { - let r = chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force)) if case let .groupMemberRatchetSyncStarted(_, _, member, connectionStats) = r { return (member, connectionStats) } - throw r + throw r.unexpected } func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) { - let r = await chatSendCmd(.apiGetContactCode(contactId: contactId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetContactCode(contactId: contactId)) if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) } - throw r + throw r.unexpected } func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, String) { - let r = await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) } - throw r + throw r.unexpected } func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? { - let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) - if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } + let r: APIResult = chatApiSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) + if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) } logger.error("apiVerifyContact error: \(String(describing: r))") return nil } func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? { - let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) - if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } + let r: APIResult = chatApiSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) + if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) } logger.error("apiVerifyGroupMember error: \(String(describing: r))") 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: APIResult = await chatApiSendCmd(.apiAddContact(userId: userId, short: short, incognito: incognito), bgTask: false) + if case let .result(.invitation(_, connLinkInv, connection)) = r { return ((connLinkInv, connection), nil) } let alert = connectionErrorAlert(r) return (nil, alert) } func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? { - let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito)) if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection } - throw r + throw r.unexpected } -func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? { - let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) - +func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection { + let r: ChatResponse1 = try await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) + if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection} - throw r + throw r.unexpected } -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: APIResult = await chatApiSendCmd(.apiConnectPlan(userId: userId, connLink: connLink)) + if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) } + let alert = apiConnectResponseAlert(r.unexpected) ?? 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 @@ -836,38 +915,49 @@ 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: APIResult = await chatApiSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink)) let m = ChatModel.shared switch r { - case let .sentConfirmation(_, connection): + case let .result(.sentConfirmation(_, connection)): return ((.invitation, connection), nil) - case let .sentInvitation(_, connection): + case let .result(.sentInvitation(_, connection)): return ((.contact, connection), nil) - case let .contactAlreadyExists(_, contact): + case let .result(.contactAlreadyExists(_, contact)): if let c = m.getContactChat(contact.contactId) { ItemsModel.shared.loadOpenChat(c.id) } let alert = contactAlreadyExistsAlert(contact) return (nil, alert) - case .chatCmdError(_, .error(.invalidConnReq)): - let alert = mkAlert( + default: () + } + let alert = apiConnectResponseAlert(r.unexpected) ?? connectionErrorAlert(r) + return (nil, alert) +} + +private func apiConnectResponseAlert(_ r: ChatError) -> Alert? { + switch r { + case .error(.invalidConnReq): + 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(_, .errorAgent(.SMP(_, .AUTH))): - let alert = mkAlert( + case .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 .errorAgent(.SMP(_, .AUTH)): + 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( + case let .errorAgent(.SMP(_, .BLOCKED(info))): + Alert( title: Text("Connection blocked"), message: Text("Connection is blocked by server operator:\n\(info.reason.text)"), primaryButton: .default(Text("Ok")), @@ -877,25 +967,22 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi } } ) - return (nil, alert) - case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))): - let alert = mkAlert( + case .errorAgent(.SMP(_, .QUOTA)): + 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))): + case let .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 { @@ -905,13 +992,13 @@ func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { ) } -private func connectionErrorAlert(_ r: ChatResponse) -> Alert { +private func connectionErrorAlert(_ r: APIResult) -> Alert { if let networkErrorAlert = networkErrorAlert(r) { return networkErrorAlert } else { return mkAlert( title: "Connection error", - message: "Error: \(responseError(r))" + message: "Error: \(responseError(r.unexpected))" ) } } @@ -921,9 +1008,9 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co logger.error("apiConnectContactViaAddress: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId)) - if case let .sentInvitationToContact(_, contact, _) = r { return (contact, nil) } - logger.error("apiConnectContactViaAddress error: \(responseError(r))") + let r: APIResult = await chatApiSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId)) + if case let .result(.sentInvitationToContact(_, contact, _)) = r { return (contact, nil) } + logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))") let alert = connectionErrorAlert(r) return (nil, alert) } @@ -932,11 +1019,11 @@ func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = . let chatId = type.rawValue + id.description DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) } defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .group = type, case .groupDeletedUser = r { return } - throw r + throw r.unexpected } func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact { @@ -950,9 +1037,9 @@ func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } } - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) if case let .contactDeleted(_, contact) = r { return contact } - throw r + throw r.unexpected } func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async { @@ -1003,9 +1090,9 @@ func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(noti func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { - let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } - throw r + throw r.unexpected } func clearChat(_ chat: Chat) async { @@ -1020,119 +1107,132 @@ func clearChat(_ chat: Chat) async { func apiListContacts() throws -> [Contact] { let userId = try currentUserId("apiListContacts") - let r = chatSendCmdSync(.apiListContacts(userId: userId)) + let r: ChatResponse1 = try chatSendCmdSync(.apiListContacts(userId: userId)) if case let .contactsList(_, contacts) = r { return contacts } - throw r + throw r.unexpected } func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { let userId = try currentUserId("apiUpdateProfile") - let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) + let r: APIResult = await chatApiSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { - case .userProfileNoChange: return (profile, []) - case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts) - case .chatCmdError(_, .errorStore(.duplicateName)): return nil; - default: throw r + case .result(.userProfileNoChange): return (profile, []) + case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts) + case .error(.errorStore(.duplicateName)): return nil; + default: throw r.unexpected } } func apiSetProfileAddress(on: Bool) async throws -> User? { let userId = try currentUserId("apiSetProfileAddress") - let r = await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on)) switch r { case .userProfileNoChange: return nil case let .userProfileUpdated(user, _, _, _): return user - default: throw r + default: throw r.unexpected } } func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? { - let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact } - throw r + throw r.unexpected } func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { - let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) if case let .contactAliasUpdated(_, toContact) = r { return toContact } - throw r + throw r.unexpected } func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? { - let r = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias)) if case let .groupAliasUpdated(_, toGroup) = r { return toGroup } - throw r + throw r.unexpected } func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { - let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } - throw r + throw r.unexpected } func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool { - let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes)) - if case .cmdOk = r { return true } - logger.error("apiSetUserUIThemes bad response: \(String(describing: r))") - return false + do { + try await sendCommandOkResp(.apiSetUserUIThemes(userId: userId, themes: themes)) + return true + } catch { + logger.error("apiSetUserUIThemes bad response: \(responseError(error))") + return false + } } func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool { - let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes)) - if case .cmdOk = r { return true } - logger.error("apiSetChatUIThemes bad response: \(String(describing: r))") - return false + do { + try await sendCommandOkResp(.apiSetChatUIThemes(chatId: chatId, themes: themes)) + return true + } catch { + logger.error("apiSetChatUIThemes bad response: \(responseError(error))") + return false + } } -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 } - throw r + let r: ChatResponse1 = try await chatSendCmd(.apiCreateMyAddress(userId: userId, short: short)) + if case let .userContactLinkCreated(_, connLink) = r { return connLink } + throw r.unexpected } func apiDeleteUserAddress() async throws -> User? { let userId = try currentUserId("apiDeleteUserAddress") - let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId)) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMyAddress(userId: userId)) if case let .userContactLinkDeleted(user) = r { return user } - throw r + throw r.unexpected } func apiGetUserAddress() throws -> UserContactLink? { let userId = try currentUserId("apiGetUserAddress") - return try userAddressResponse(chatSendCmdSync(.apiShowMyAddress(userId: userId))) + return try userAddressResponse(chatApiSendCmdSync(.apiShowMyAddress(userId: userId))) } func apiGetUserAddressAsync() async throws -> UserContactLink? { let userId = try currentUserId("apiGetUserAddressAsync") - return try userAddressResponse(await chatSendCmd(.apiShowMyAddress(userId: userId))) + return try userAddressResponse(await chatApiSendCmd(.apiShowMyAddress(userId: userId))) } -private func userAddressResponse(_ r: ChatResponse) throws -> UserContactLink? { +private func userAddressResponse(_ r: APIResult) throws -> UserContactLink? { switch r { - case let .userContactLink(_, contactLink): return contactLink - case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil - default: throw r + case let .result(.userContactLink(_, contactLink)): return contactLink + case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil + default: throw r.unexpected } } +func apiAddMyAddressShortLink() async throws -> UserContactLink { + let userId = try currentUserId("apiAddMyAddressShortLink") + let r: ChatResponse1 = try await chatSendCmd(.apiAddMyAddressShortLink(userId: userId)) + if case let .userContactLink(_, contactLink) = r { return contactLink } + throw r.unexpected +} + func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? { let userId = try currentUserId("userAddressAutoAccept") - let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) + let r: APIResult = await chatApiSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) switch r { - case let .userContactLinkUpdated(_, contactLink): return contactLink - case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil - default: throw r + case let .result(.userContactLinkUpdated(_, contactLink)): return contactLink + case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil + default: throw r.unexpected } } func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? { - let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId)) + let r: APIResult = await chatApiSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId)) let am = AlertManager.shared - if case let .acceptingContactRequest(_, contact) = r { return contact } - if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r { + if case let .result(.acceptingContactRequest(_, contact)) = r { return contact } + if case .error(.errorAgent(.SMP(_, .AUTH))) = r { am.showAlertMsg( title: "Connection error (AUTH)", message: "Sender may have deleted the connection request." @@ -1143,24 +1243,26 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont logger.error("apiAcceptContactRequest error: \(String(describing: r))") am.showAlertMsg( title: "Error accepting contact request", - message: "Error: \(responseError(r))" + message: "Error: \(responseError(r.unexpected))" ) } return nil } func apiRejectContactRequest(contactReqId: Int64) async throws { - let r = await chatSendCmd(.apiRejectContact(contactReqId: contactReqId)) + let r: ChatResponse1 = try await chatSendCmd(.apiRejectContact(contactReqId: contactReqId)) if case .contactRequestRejected = r { return } - throw r + throw r.unexpected } -func apiChatRead(type: ChatType, id: Int64) async throws { - try await sendCommandOkResp(.apiChatRead(type: type, id: id)) +func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws { + try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) } -func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws { - try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds)) +func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo { + let r: ChatResponse1 = try await chatSendCmd(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds)) + if case let .itemsReadForChat(_, updatedChatInfo) = r { return updatedChatInfo } + throw r.unexpected } func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { @@ -1168,31 +1270,33 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { } func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) { - let r = await chatSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl) - if case let .sndStandaloneFileCreated(_, fileTransferMeta) = r { + let r: APIResult = await chatApiSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl: ctrl) + if case let .result(.sndStandaloneFileCreated(_, fileTransferMeta)) = r { return (fileTransferMeta, nil) } else { - logger.error("uploadStandaloneFile error: \(String(describing: r))") - return (nil, responseError(r)) + let err = responseError(r.unexpected) + logger.error("uploadStandaloneFile error: \(err)") + return (nil, err) } } func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) { - let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl) - if case let .rcvStandaloneFileCreated(_, rcvFileTransfer) = r { + let r: APIResult = await chatApiSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl: ctrl) + if case let .result(.rcvStandaloneFileCreated(_, rcvFileTransfer)) = r { return (rcvFileTransfer, nil) } else { - logger.error("downloadStandaloneFile error: \(String(describing: r))") - return (nil, responseError(r)) + let err = responseError(r.unexpected) + logger.error("downloadStandaloneFile error: \(err)") + return (nil, err) } } func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? { - let r = await chatSendCmd(.apiStandaloneFileInfo(url: url), ctrl) - if case let .standaloneFileInfo(fileMeta) = r { + let r: APIResult = await chatApiSendCmd(.apiStandaloneFileInfo(url: url), ctrl: ctrl) + if case let .result(.standaloneFileInfo(fileMeta)) = r { return fileMeta } else { - logger.error("standaloneFileInfo error: \(String(describing: r))") + logger.error("standaloneFileInfo error: \(responseError(r.unexpected))") return nil } } @@ -1207,12 +1311,12 @@ func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = f } func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async { - var fileIdsToApprove = [Int64]() - var srvsToApprove = Set() - var otherFileErrs = [ChatResponse]() - + var fileIdsToApprove: [Int64] = [] + var srvsToApprove: Set = [] + var otherFileErrs: [APIResult] = [] + for fileId in fileIds { - let r = await chatSendCmd( + let r: APIResult = await chatApiSendCmd( .receiveFile( fileId: fileId, userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(), @@ -1221,32 +1325,22 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool ) ) switch r { - case let .rcvFileAccepted(_, chatItem): + case let .result(.rcvFileAccepted(_, chatItem)): await chatItemSimpleUpdate(user, chatItem) + // TODO when aChatItem added + // case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): + // await chatItemSimpleUpdate(user, aChatItem) + // Task { cleanupFile(aChatItem) } + case let .error(.error(.fileNotApproved(fileId, unknownServers))): + fileIdsToApprove.append(fileId) + srvsToApprove.formUnion(unknownServers) default: - if let chatError = chatError(r) { - switch chatError { - case let .fileNotApproved(fileId, unknownServers): - fileIdsToApprove.append(fileId) - srvsToApprove.formUnion(unknownServers) - default: - otherFileErrs.append(r) - } - } + otherFileErrs.append(r) } } - + if !auto { - let otherErrsStr = if otherFileErrs.isEmpty { - "" - } else if otherFileErrs.count == 1 { - "\(otherFileErrs[0])" - } else if otherFileErrs.count == 2 { - "\(otherFileErrs[0])\n\(otherFileErrs[1])" - } else { - "\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)" - } - + let otherErrsStr = fileErrorStrs(otherFileErrs) // If there are not approved files, alert is shown the same way both in case of singular and plural files reception if !fileIdsToApprove.isEmpty { let srvs = srvsToApprove @@ -1282,7 +1376,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it let errorResponse = otherFileErrs.first! switch errorResponse { - case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer): + case let .result(.rcvFileAcceptedSndCancelled(_, rcvFileTransfer)): logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)") await MainActor.run { showAlert( @@ -1290,19 +1384,14 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message") ) } + case .error(.error(.fileCancelled)), .error(.error(.fileAlreadyReceiving)): + logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") default: - if let chatError = chatError(errorResponse) { - switch chatError { - case .fileCancelled, .fileAlreadyReceiving: - logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") - default: - await MainActor.run { - showAlert( - NSLocalizedString("Error receiving file", comment: "alert title"), - message: responseError(errorResponse) - ) - } - } + await MainActor.run { + showAlert( + NSLocalizedString("Error receiving file", comment: "alert title"), + message: responseError(errorResponse.unexpected) + ) } } } else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert @@ -1314,8 +1403,22 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } } } + + func fileErrorStrs(_ errs: [APIResult]) -> String { + var errStr = "" + if errs.count >= 1 { + errStr = String(describing: errs[0].unexpected) + } + if errs.count >= 2 { + errStr += "\n\(String(describing: errs[1].unexpected))" + } + if errs.count > 2 { + errStr += "\nand \(errs.count - 2) other error(s)" + } + return errStr + } } - + func cancelFile(user: User, fileId: Int64) async { if let chatItem = await apiCancelFile(fileId: fileId) { await chatItemSimpleUpdate(user, chatItem) @@ -1324,12 +1427,12 @@ func cancelFile(user: User, fileId: Int64) async { } func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? { - let r = await chatSendCmd(.cancelFile(fileId: fileId), ctrl) + let r: APIResult = await chatApiSendCmd(.cancelFile(fileId: fileId), ctrl: ctrl) switch r { - case let .sndFileCancelled(_, chatItem, _, _) : return chatItem - case let .rcvFileCancelled(_, chatItem, _) : return chatItem + case let .result(.sndFileCancelled(_, chatItem, _, _)) : return chatItem + case let .result(.rcvFileCancelled(_, chatItem, _)) : return chatItem default: - logger.error("apiCancelFile error: \(String(describing: r))") + logger.error("apiCancelFile error: \(responseError(r.unexpected))") return nil } } @@ -1339,9 +1442,9 @@ func setLocalDeviceName(_ displayName: String) throws { } func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { - let r = await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) + let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } - throw r + throw r.unexpected } func findKnownRemoteCtrl() async throws { @@ -1349,21 +1452,21 @@ func findKnownRemoteCtrl() async throws { } func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { - let r = await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId)) + let r: ChatResponse2 = try await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } - throw r + throw r.unexpected } func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo { - let r = await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode)) + let r: ChatResponse2 = try await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode)) if case let .remoteCtrlConnected(rc) = r { return rc } - throw r + throw r.unexpected } func listRemoteCtrls() throws -> [RemoteCtrlInfo] { - let r = chatSendCmdSync(.listRemoteCtrls) + let r: ChatResponse2 = try chatSendCmdSync(.listRemoteCtrls) if case let .remoteCtrlList(rcInfo) = r { return rcInfo } - throw r + throw r.unexpected } func stopRemoteCtrl() async throws { @@ -1374,8 +1477,8 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws { try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId)) } -func networkErrorAlert(_ r: ChatResponse) -> Alert? { - if let alert = getNetworkErrorAlert(r) { +func networkErrorAlert(_ res: APIResult) -> Alert? { + if case let .error(e) = res, let alert = getNetworkErrorAlert(e) { return mkAlert(title: alert.title, message: alert.message) } else { return nil @@ -1437,15 +1540,15 @@ func apiEndCall(_ contact: Contact) async throws { } func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] { - let r = chatSendCmdSync(.apiGetCallInvitations) + let r: ChatResponse2 = try chatSendCmdSync(.apiGetCallInvitations) if case let .callInvitations(invs) = r { return invs } - throw r + throw r.unexpected } func apiGetCallInvitations() async throws -> [RcvCallInvitation] { - let r = await chatSendCmd(.apiGetCallInvitations) + let r: ChatResponse2 = try await chatSendCmd(.apiGetCallInvitations) if case let .callInvitations(invs) = r { return invs } - throw r + throw r.unexpected } func apiCallStatus(_ contact: Contact, _ status: String) async throws { @@ -1457,18 +1560,18 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { - let r = chatSendCmdSync(.apiGetNetworkStatuses) + let r: ChatResponse1 = try chatSendCmdSync(.apiGetNetworkStatuses) if case let .networkStatuses(_, statuses) = r { return statuses } - throw r + throw r.unexpected } -func markChatRead(_ chat: Chat) async { +func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo - try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) + try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope()) await MainActor.run { - withAnimation { ChatModel.shared.markChatItemsRead(cInfo) } + withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) } } } if chat.chatStats.unreadChat { @@ -1491,40 +1594,41 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { } } -func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async { +func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { - try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds) - DispatchQueue.main.async { - ChatModel.shared.markChatItemsRead(cInfo, itemIds) + let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) + await MainActor.run { + ChatModel.shared.updateChatInfo(updatedChatInfo) + ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead) } } catch { logger.error("apiChatItemsRead error: \(responseError(error))") } } -private func sendCommandOkResp(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) async throws { - let r = await chatSendCmd(cmd, ctrl) +private func sendCommandOkResp(_ cmd: ChatCommand, ctrl: chat_ctrl? = nil) async throws { + let r: ChatResponse2 = try await chatSendCmd(cmd, ctrl: ctrl) if case .cmdOk = r { return } - throw r + throw r.unexpected } private func sendCommandOkRespSync(_ cmd: ChatCommand) throws { - let r = chatSendCmdSync(cmd) + let r: ChatResponse2 = try chatSendCmdSync(cmd) if case .cmdOk = r { return } - throw r + throw r.unexpected } func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") - let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) + let r: ChatResponse2 = try chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } - throw r + throw r.unexpected } func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { - let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) + let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) if case let .sentGroupInvitation(_, _, _, member) = r { return member } - throw r + throw r.unexpected } enum JoinGroupResult { @@ -1534,31 +1638,43 @@ enum JoinGroupResult { } func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { - let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) + let r: APIResult = await chatApiSendCmd(.apiJoinGroup(groupId: groupId)) switch r { - case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo) - case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved - case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound - default: throw r + case let .result(.userAcceptedGroupSent(_, groupInfo, _)): return .joined(groupInfo: groupInfo) + case .error(.errorAgent(.SMP(_, .AUTH))): return .invitationRemoved + case .error(.errorStore(.groupNotFound)): return .groupNotFound + default: throw r.unexpected } } -func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember { - let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false) - if case let .userDeletedMember(_, _, member) = r { return member } - throw r +func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) { + let r: ChatResponse2 = try await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole)) + if case let .memberAccepted(_, groupInfo, member) = r { return (groupInfo, member) } + throw r.unexpected } -func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { - let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false) - if case let .memberRoleUser(_, _, member, _, _) = r { return member } - throw r +func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupInfo, GroupMember) { + let r: ChatResponse2 = try await chatSendCmd(.apiDeleteMemberSupportChat(groupId: groupId, groupMemberId: groupMemberId)) + if case let .memberSupportChatDeleted(_, groupInfo, member) = r { return (groupInfo, member) } + throw r.unexpected } -func apiBlockMemberForAll(_ groupId: Int64, _ memberId: Int64, _ blocked: Bool) async throws -> GroupMember { - let r = await chatSendCmd(.apiBlockMemberForAll(groupId: groupId, memberId: memberId, blocked: blocked), bgTask: false) - if case let .memberBlockedForAllUser(_, _, member, _) = r { return member } - throw r +func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (GroupInfo, [GroupMember]) { + let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) + if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) } + throw r.unexpected +} + +func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] { + let r: ChatResponse2 = try await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false) + if case let .membersRoleUser(_, _, members, _) = r { return members } + throw r.unexpected +} + +func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] { + let r: ChatResponse2 = try await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false) + if case let .membersBlockedForAllUser(_, _, members, _) = r { return members } + throw r.unexpected } func leaveGroup(_ groupId: Int64) async { @@ -1571,92 +1687,100 @@ func leaveGroup(_ groupId: Int64) async { } func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { - let r = await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) + let r: ChatResponse2 = try await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) if case let .leftMemberUser(_, groupInfo) = r { return groupInfo } - throw r + throw r.unexpected } +// use ChatModel's loadGroupMembers from views func apiListMembers(_ groupId: Int64) async -> [GroupMember] { - let r = await chatSendCmd(.apiListMembers(groupId: groupId)) - if case let .groupMembers(_, group) = r { return group.members } + let r: APIResult = await chatApiSendCmd(.apiListMembers(groupId: groupId)) + if case let .result(.groupMembers(_, group)) = r { return group.members } return [] } func filterMembersToAdd(_ ms: [GMember]) -> [Contact] { let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } return ChatModel.shared.chats - .compactMap{ $0.chatInfo.contact } - .filter{ c in c.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) } + .compactMap{ c in c.chatInfo.sendMsgEnabled ? c.chatInfo.contact : nil } + .filter{ c in !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) } .sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() } } func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws -> GroupInfo { - let r = await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile)) + let r: ChatResponse2 = try await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile)) if case let .groupUpdated(_, toGroup) = r { return toGroup } - throw r + throw r.unexpected } -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) } - throw r +func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { + let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) + let r: ChatResponse2 = try await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short)) + if case let .groupLinkCreated(_, _, connLink, memberRole) = r { return (connLink, memberRole) } + throw r.unexpected } -func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { - let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) - if case let .groupLink(_, _, connReq, memberRole) = r { return (connReq, memberRole) } - throw r +func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { + let r: ChatResponse2 = try await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) + if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } + throw r.unexpected } func apiDeleteGroupLink(_ groupId: Int64) async throws { - let r = await chatSendCmd(.apiDeleteGroupLink(groupId: groupId)) + let r: ChatResponse2 = try await chatSendCmd(.apiDeleteGroupLink(groupId: groupId)) if case .groupLinkDeleted = r { return } - throw r + throw r.unexpected } -func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? { - let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) +func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRole)? { + let r: APIResult = chatApiSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, _, connReq, memberRole): - return (connReq, memberRole) - case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): + case let .result(.groupLink(_, _, connLink, memberRole)): + return (connLink, memberRole) + case .error(.errorStore(storeError: .groupLinkNotFound)): return nil - default: throw r + default: throw r.unexpected } } +func apiAddGroupShortLink(_ groupId: Int64) async throws -> (CreatedConnLink, GroupMemberRole) { + let r: ChatResponse2 = try await chatSendCmd(.apiAddGroupShortLink(groupId: groupId)) + if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } + throw r.unexpected +} + func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact { - let r = await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse2 = try await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId)) if case let .newMemberContact(_, contact, _, _) = r { return contact } - throw r + throw r.unexpected } func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async throws -> Contact { - let r = await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay) + let r: ChatResponse2 = try await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay) if case let .newMemberContactSentInv(_, contact, _, _) = r { return contact } - throw r + throw r.unexpected } func apiGetVersion() throws -> CoreVersionInfo { - let r = chatSendCmdSync(.showVersion) + let r: ChatResponse2 = try chatSendCmdSync(.showVersion) if case let .versionInfo(info, _, _) = r { return info } - throw r + throw r.unexpected } func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) { let userId = try currentUserId("getAgentSubsTotal") - let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false) + let r: ChatResponse2 = try await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false) if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) } logger.error("getAgentSubsTotal error: \(String(describing: r))") - throw r + throw r.unexpected } func getAgentServersSummary() throws -> PresentedServersSummary { let userId = try currentUserId("getAgentServersSummary") - let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false) + let r: ChatResponse2 = try chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false) if case let .agentServersSummary(_, serversSummary) = r { return serversSummary } logger.error("getAgentServersSummary error: \(String(describing: r))") - throw r + throw r.unexpected } func resetAgentServersStats() async throws { @@ -1866,7 +1990,7 @@ class ChatReceiver { private var receiveMessages = true private var _lastMsgTime = Date.now - var messagesChannel: ((ChatResponse) -> Void)? = nil + var messagesChannel: ((APIResult) -> Void)? = nil static let shared = ChatReceiver() @@ -1884,7 +2008,12 @@ class ChatReceiver { while self.receiveMessages { if let msg = await chatRecvMsg() { self._lastMsgTime = .now - await processReceivedMsg(msg) + Task { await TerminalItems.shared.addResult(msg) } + switch msg { + case let .result(evt): await processReceivedMsg(evt) + case let .error(err): logger.debug("chatRecvMsg error: \(responseError(err))") + case let .invalid(type, json): logger.debug("chatRecvMsg event: * \(type) \(dataToString(json))") + } if let messagesChannel { messagesChannel(msg) } @@ -1901,10 +2030,7 @@ class ChatReceiver { } } -func processReceivedMsg(_ res: ChatResponse) async { - Task { - await TerminalItems.shared.add(.resp(.now, res)) - } +func processReceivedMsg(_ res: ChatEvent) async { let m = ChatModel.shared let n = NetworkModel.shared logger.debug("processReceivedMsg: \(res.responseType)") @@ -2027,7 +2153,7 @@ func processReceivedMsg(_ res: ChatResponse) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } - } else if cItem.isRcvNew && cInfo.ntfsEnabled { + } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } } @@ -2045,7 +2171,7 @@ func processReceivedMsg(_ res: ChatResponse) async { let cInfo = chatItem.chatInfo let cItem = chatItem.chatItem if !cItem.isDeletedContent && active(user) { - await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) } + _ = await MainActor.run { m.upsertChatItem(cInfo, cItem) } } if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { @@ -2072,7 +2198,8 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .chatItemsDeleted(user, items, _): if !active(user) { for item in items { - if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled { + let d = item.deletedChatItem + if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) { await MainActor.run { m.decreaseUnreadCounter(user: user) } @@ -2088,42 +2215,16 @@ func processReceivedMsg(_ res: ChatResponse) async { } else { m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem) } + if item.deletedChatItem.chatItem.isActiveReport { + m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id) + } + } + if let updatedChatInfo = items.last?.deletedChatItem.chatInfo { + m.updateChatInfo(updatedChatInfo) } } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): - if !active(user) { - do { - let users = try listUsers() - await MainActor.run { - m.users = users - } - } catch { - logger.error("Error loading users: \(error)") - } - return - } - let im = ItemsModel.shared - let cInfo = ChatInfo.group(groupInfo: groupInfo) - await MainActor.run { - m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) - } - var notFound = chatItemIDs.count - for ci in im.reversedChatItems { - if chatItemIDs.contains(ci.id) { - let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { - CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) - } else { - CIDeleted.deleted(deletedTs: Date.now) - } - await MainActor.run { - var newItem = ci - newItem.meta.itemDeleted = deleted - _ = m.upsertChatItem(cInfo, newItem) - } - notFound -= 1 - if notFound == 0 { break } - } - } + await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) case let .receivedGroupInvitation(user, groupInfo, _, _): if active(user) { await MainActor.run { @@ -2143,7 +2244,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .groupLinkConnecting(user, groupInfo, hostMember): if !active(user) { return } - + await MainActor.run { m.updateGroup(groupInfo) if let hostConn = hostMember.activeConn { @@ -2169,21 +2270,36 @@ func processReceivedMsg(_ res: ChatResponse) async { _ = m.upsertGroupMember(groupInfo, member) } } - case let .deletedMemberUser(user, groupInfo, _): // TODO update user member + case let .memberAcceptedByOther(user, groupInfo, _, member): if active(user) { await MainActor.run { + _ = m.upsertGroupMember(groupInfo, member) m.updateGroup(groupInfo) } } - case let .deletedMember(user, groupInfo, _, deletedMember): + case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member if active(user) { await MainActor.run { + m.updateGroup(groupInfo) + if withMessages { + m.removeMemberItems(groupInfo.membership, byMember: member, groupInfo) + } + } + } + case let .deletedMember(user, groupInfo, byMember, deletedMember, withMessages): + if active(user) { + await MainActor.run { + m.updateGroup(groupInfo) _ = m.upsertGroupMember(groupInfo, deletedMember) + if withMessages { + m.removeMemberItems(deletedMember, byMember: byMember, groupInfo) + } } } case let .leftMember(user, groupInfo, member): if active(user) { await MainActor.run { + m.updateGroup(groupInfo) _ = m.upsertGroupMember(groupInfo, member) } } @@ -2198,6 +2314,12 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { m.updateGroup(groupInfo) } + if m.chatId == groupInfo.id, + case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo { + await MainActor.run { + m.secondaryPendingInviteeChatOpened = false + } + } } case let .joinedGroupMember(user, groupInfo, member): if active(user) { @@ -2244,6 +2366,10 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE await chatItemSimpleUpdate(user, aChatItem) +// TODO when aChatItem added +// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): // usually rcvFileAcceptedSndCancelled is a response, but it's also an event for XFTP files auto-accepted from NSE +// await chatItemSimpleUpdate(user, aChatItem) +// Task { cleanupFile(aChatItem) } case let .rcvFileStart(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): @@ -2466,6 +2592,43 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async { } } +func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemIDs: Set, _ member_: GroupMember?) async { + let m = ChatModel.shared + if !active(user) { + do { + let users = try listUsers() + await MainActor.run { + m.users = users + } + } catch { + logger.error("Error loading users: \(error)") + } + return + } + let im = ItemsModel.shared + let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil) + await MainActor.run { + m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) + } + var notFound = chatItemIDs.count + for ci in im.reversedChatItems { + if chatItemIDs.contains(ci.id) { + let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { + CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) + } else { + CIDeleted.deleted(deletedTs: Date.now) + } + await MainActor.run { + var newItem = ci + newItem.meta.itemDeleted = deleted + _ = m.upsertChatItem(cInfo, newItem) + } + notFound -= 1 + if notFound == 0 { break } + } + } +} + func refreshCallInvitations() async throws { let m = ChatModel.shared let callInvitations = try await apiGetCallInvitations() diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 10120db185..47c0f61c79 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 } } } @@ -145,7 +159,7 @@ struct SimpleXApp: App { if let id = chatModel.chatId, let chat = chatModel.getChat(id), !NtfManager.shared.navigatingToChat { - Task { await loadChat(chat: chat, clearItems: false) } + Task { await loadChat(chat: chat, im: ItemsModel.shared, clearItems: false) } } if let ncr = chatModel.ntfContactRequest { await MainActor.run { chatModel.ntfContactRequest = nil } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 2f76f1f046..ab7a47b944 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -243,7 +243,7 @@ struct ActiveCallView: View { ChatReceiver.shared.messagesChannel = nil return } - if case let .chatItemsStatusesUpdated(_, chatItems) = msg, + if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg, chatItems.contains(where: { ci in ci.chatInfo.id == call.contact.id && ci.chatItem.content.isSndCall && @@ -361,7 +361,7 @@ struct ActiveCallOverlay: View { HStack { Text(call.encryptionStatus) if let connInfo = call.connectionInfo { - Text("(") + Text(connInfo.text) + Text(")") + Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")") } } } @@ -390,7 +390,7 @@ struct ActiveCallOverlay: View { HStack { Text(call.encryptionStatus) if let connInfo = call.connectionInfo { - Text("(") + Text(connInfo.text) + Text(")") + Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")") } } } @@ -467,7 +467,7 @@ struct ActiveCallOverlay: View { .disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true) } - @ViewBuilder private func flipCameraButton() -> some View { + private func flipCameraButton() -> some View { controlButton(call, "arrow.triangle.2.circlepath", padding: 12) { Task { if await WebRTCClient.isAuthorized(for: .video) { @@ -477,11 +477,11 @@ struct ActiveCallOverlay: View { } } - @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { + private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform) } - @ViewBuilder private func audioDevicePickerButton() -> some View { + private func audioDevicePickerButton() -> some View { AudioDevicePicker() .opacity(0.8) .scaleEffect(2) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 62a41c504a..b60842a4a0 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -22,11 +22,28 @@ struct ChatInfoToolbar: View { Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo) Spacer().frame(width: 16) } - ChatInfoImage( - chat: chat, - size: imageSize, - color: Color(uiColor: .tertiaryLabel) - ) + ZStack(alignment: .bottomTrailing) { + ChatInfoImage( + chat: chat, + size: imageSize, + color: Color(uiColor: .tertiaryLabel) + ) + if chat.chatStats.reportsCount > 0 { + Image(systemName: "flag.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .red) + } else if chat.supportUnreadCount > 0 { + Image(systemName: "flag.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, theme.colors.primary) + } + } .padding(.trailing, 4) let t = Text(cInfo.displayName).font(.headline) (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 7a5003c94d..0498dc5d70 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 { @@ -158,7 +158,9 @@ struct ChatInfoView: View { searchButton(width: buttonWidth) AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - muteButton(width: buttonWidth) + if let nextNtfMode = chat.chatInfo.nextNtfMode { + muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) + } } } .padding(.trailing) @@ -432,13 +434,13 @@ struct ChatInfoView: View { .disabled(!contact.ready || chat.chatItems.isEmpty) } - private func muteButton(width: CGFloat) -> some View { - InfoViewButton( - image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill", - title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute", + private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { + return InfoViewButton( + image: nextNtfMode.iconFilled, + title: "\(nextNtfMode.text(mentions: false))", width: width ) { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + toggleNotifications(chat, enableNtfs: nextNtfMode) } .disabled(!contact.ready || !contact.active) } @@ -682,17 +684,23 @@ struct ChatTTLOption: View { ) { progressIndicator = true Task { + let m = ChatModel.shared do { try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl) - await loadChat(chat: chat, clearItems: true, replaceChat: true) + await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true) await MainActor.run { progressIndicator = false currentChatItemTTL = chatItemTTL + if ItemsModel.shared.reversedChatItems.isEmpty && m.chatId == chat.id, + let chat = m.getChat(chat.id) { + chat.chatItems = [] + m.replaceChat(chat.id, chat) + } } } catch let error { logger.error("setChatTTL error \(responseError(error))") - await loadChat(chat: chat, clearItems: true, replaceChat: true) + await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true) await MainActor.run { chatItemTTL = currentChatItemTTL progressIndicator = false @@ -930,7 +938,7 @@ struct ChatWallpaperEditorSheet: View { self.chat = chat self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes { uiThemes - } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { + } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { uiThemes } else { ThemeModeOverrides() @@ -966,7 +974,7 @@ struct ChatWallpaperEditorSheet: View { private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides { if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes { uiThemes - } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { + } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { uiThemes } else { ThemeModeOverrides() @@ -1044,12 +1052,12 @@ struct ChatWallpaperEditorSheet: View { chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact)) themes = themesFromChat(chat.wrappedValue) } - } else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo { + } else if case var ChatInfo.group(groupInfo, _) = chat.wrappedValue.chatInfo { groupInfo.uiThemes = changedThemesConstant await MainActor.run { - ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo)) - chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo)) + ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)) + chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)) themes = themesFromChat(chat.wrappedValue) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 3b3e1b3899..0283e9c07e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -50,7 +50,7 @@ struct CICallItemView: View { Image(systemName: "phone.connection").foregroundColor(.green) } - @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View { + private func endedCallIcon(_ sent: Bool) -> some View { HStack { Image(systemName: "phone.down") Text(durationText(duration)).foregroundColor(theme.colors.secondary) @@ -60,16 +60,16 @@ struct CICallItemView: View { @ViewBuilder private func acceptCallButton() -> some View { if case let .direct(contact) = chat.chatInfo { - Button { - if let invitation = m.callInvitations[contact.id] { - CallController.shared.answerCall(invitation: invitation) - logger.debug("acceptCallButton call answered") - } else { - AlertManager.shared.showAlertMsg(title: "Call already ended!") - } - } label: { - Label("Answer call", systemImage: "phone.arrow.down.left") - } + Label("Answer call", systemImage: "phone.arrow.down.left") + .foregroundColor(theme.colors.primary) + .simultaneousGesture(TapGesture().onEnded { + if let invitation = m.callInvitations[contact.id] { + CallController.shared.answerCall(invitation: invitation) + logger.debug("acceptCallButton call answered") + } else { + AlertManager.shared.showAlertMsg(title: "Call already ended!") + } + }) } else { Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index 02be8af73b..b2b4441646 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -12,8 +12,8 @@ import SimpleXChat struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel @Environment(\.revealed) var revealed: Bool - @ObservedObject var im = ItemsModel.shared @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @EnvironmentObject var theme: AppTheme var chatItem: ChatItem var feature: Feature @@ -53,7 +53,7 @@ struct CIChatFeatureView: View { private func mergedFeatures() -> [FeatureInfo]? { var fs: [FeatureInfo] = [] var icons: Set = [] - if var i = m.getChatItemIndex(chatItem) { + if var i = m.getChatItemIndex(im, chatItem) { while i < im.reversedChatItems.count, let f = featureInfo(im.reversedChatItems[i]) { if !icons.contains(f.icon) { @@ -108,6 +108,7 @@ struct CIChatFeatureView_Previews: PreviewProvider { let enabled = FeatureEnabled(forUser: false, forContact: false) CIChatFeatureView( chat: Chat.sampleData, + im: ItemsModel.shared, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary) ).environment(\.revealed, true) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index 2c9c261536..67f7b69e2c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -26,9 +26,9 @@ struct CIFeaturePreferenceView: View { allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) { let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept") - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil) - } + }) } else { featurePreferenceView() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index a785f3e6d8..1b9376b5db 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -19,42 +19,42 @@ struct CIFileView: View { var body: some View { if smallViewSize != nil { fileIndicator() - .onTapGesture(perform: fileAction) + .simultaneousGesture(TapGesture().onEnded(fileAction)) } else { let metaReserve = edited ? " " : " " - Button(action: fileAction) { - HStack(alignment: .bottom, spacing: 6) { - fileIndicator() - .padding(.top, 5) - .padding(.bottom, 3) - if let file = file { - let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) - VStack(alignment: .leading, spacing: 2) { - Text(file.fileName) - .lineLimit(1) - .multilineTextAlignment(.leading) - .foregroundColor(theme.colors.onBackground) - Text(prettyFileSize + metaReserve) - .font(.caption) - .lineLimit(1) - .multilineTextAlignment(.leading) - .foregroundColor(theme.colors.secondary) - } - } else { - Text(metaReserve) + HStack(alignment: .bottom, spacing: 6) { + fileIndicator() + .padding(.top, 5) + .padding(.bottom, 3) + if let file = file { + let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(theme.colors.onBackground) + Text(prettyFileSize + metaReserve) + .font(.caption) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(theme.colors.secondary) } + } else { + Text(metaReserve) } - .padding(.top, 4) - .padding(.bottom, 6) - .padding(.leading, 10) - .padding(.trailing, 12) } + .padding(.top, 4) + .padding(.bottom, 6) + .padding(.leading, 10) + .padding(.trailing, 12) + .simultaneousGesture(TapGesture().onEnded(fileAction)) .disabled(!itemInteractive) } } + @inline(__always) private var itemInteractive: Bool { if let file = file { switch (file.fileStatus) { @@ -278,6 +278,7 @@ func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { struct CIFileView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentFile: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -293,16 +294,16 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentFile) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample()) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10))) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation)) - ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 107208a033..3fcf578875 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -84,12 +84,12 @@ struct CIGroupInvitationView: View { } if action { - v.onTapGesture { + v.simultaneousGesture(TapGesture().onEnded { inProgress = true joinGroup(groupInvitation.groupId) { await MainActor.run { inProgress = false } } - } + }) .disabled(inProgress) } else { v diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index d491563913..d1f49f635a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem + var scrollToItem: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat var imgWidth: CGFloat? @@ -25,12 +26,14 @@ struct CIImageView: View { if let uiImage = getLoadedImage(file) { Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } } .fullScreenCover(isPresented: $showFullScreenImage) { - FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage) + FullScreenMediaView(chatItem: chatItem, scrollToItem: scrollToItem, image: uiImage, showView: $showFullScreenImage) } .if(!smallView) { view in view.modifier(PrivacyBlur(blurred: $blurred)) } - .onTapGesture { showFullScreenImage = true } + .if(!blurred) { v in + v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true }) + } .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenImage = false } @@ -42,7 +45,7 @@ struct CIImageView: View { imageView(preview).modifier(PrivacyBlur(blurred: $blurred)) } } - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { if let file = file { switch file.fileStatus { case .rcvInvitation, .rcvAborted: @@ -79,7 +82,7 @@ struct CIImageView: View { default: () } } - } + }) } } .onDisappear { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift index 18fd682646..5e9fa691de 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift @@ -7,10 +7,11 @@ // import SwiftUI +import SimpleXChat struct CIInvalidJSONView: View { @EnvironmentObject var theme: AppTheme - var json: String + var json: Data? @State private var showJSON = false var body: some View { @@ -23,16 +24,16 @@ struct CIInvalidJSONView: View { .padding(.vertical, 6) .background(Color(uiColor: .tertiarySystemGroupedBackground)) .textSelection(.disabled) - .onTapGesture { showJSON = true } + .simultaneousGesture(TapGesture().onEnded { showJSON = true }) .appSheet(isPresented: $showJSON) { - invalidJSONView(json) + invalidJSONView(dataToString(json)) } } } func invalidJSONView(_ json: String) -> some View { VStack(alignment: .leading, spacing: 16) { - Button { + Button { // this is used in the sheet, Button works here showShareSheet(items: [json]) } label: { Image(systemName: "square.and.arrow.up") @@ -49,6 +50,6 @@ func invalidJSONView(_ json: String) -> some View { struct CIInvalidJSONView_Previews: PreviewProvider { static var previews: some View { - CIInvalidJSONView(json: "{}") + CIInvalidJSONView(json: "{}".data(using: .utf8)!) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 692e6bb8a6..f9dbaede63 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -21,15 +21,15 @@ struct CILinkView: View { .resizable() .scaledToFit() .modifier(PrivacyBlur(blurred: $blurred)) + .if(!blurred) { v in + v.simultaneousGesture(TapGesture().onEnded { + openBrowserAlert(uri: linkPreview.uri) + }) + } } VStack(alignment: .leading, spacing: 6) { Text(linkPreview.title) .lineLimit(3) -// if linkPreview.description != "" { -// Text(linkPreview.description) -// .font(.subheadline) -// .lineLimit(12) -// } Text(linkPreview.uri.absoluteString) .font(.caption) .lineLimit(1) @@ -37,10 +37,32 @@ struct CILinkView: View { } .padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: .leading) + .simultaneousGesture(TapGesture().onEnded { + openBrowserAlert(uri: linkPreview.uri) + }) } } } +func openBrowserAlert(uri: URL) { + showAlert( + NSLocalizedString("Open link?", comment: "alert title"), + message: uri.absoluteString, + actions: {[ + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default, + handler: { _ in } + ), + UIAlertAction( + title: NSLocalizedString("Open", comment: "alert action"), + style: .default, + handler: { _ in UIApplication.shared.open(uri) } + ) + ]} + ) +} + struct LargeLinkPreview_Previews: PreviewProvider { static var previews: some View { let preview = LinkPreview( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index d24c737907..2898a318a9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -20,12 +20,11 @@ struct CIMemberCreatedContactView: View { case let .groupRcv(groupMember): if let contactId = groupMember.memberContactId { memberCreatedContactView(openText: "Open") - .onTapGesture { - dismissAllSheets(animated: true) - DispatchQueue.main.async { - ItemsModel.shared.loadOpenChat("@\(contactId)") + .simultaneousGesture(TapGesture().onEnded { + ItemsModel.shared.loadOpenChat("@\(contactId)") { + dismissAllSheets(animated: true) } - } + }) } else { memberCreatedContactView() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index e58ad0f74e..fc73778239 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -15,7 +15,7 @@ struct CIMetaView: View { @Environment(\.showTimestamp) var showTimestamp: Bool var chatItem: ChatItem var metaColor: Color - var paleMetaColor = Color(UIColor.tertiaryLabel) + var paleMetaColor = Color(uiColor: .tertiaryLabel) var showStatus = true var showEdited = true var invertedMaterial = false @@ -152,11 +152,13 @@ func ciMetaText( return r.font(.caption) } +@inline(__always) private func statusIconText(_ icon: String, _ color: Color?) -> Text { colored(Text(Image(systemName: icon)), color) } // Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier +@inline(__always) private func colored(_ t: Text, _ color: Color?) -> Text { if let color { t.foregroundColor(color) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 4603a026cd..3201332c1e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -45,7 +45,7 @@ struct CIRcvDecryptionError: View { viewBody() .onAppear { // for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear - if case let .group(groupInfo) = chat.chatInfo, + if case let .group(groupInfo, _) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir { do { let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId) @@ -68,7 +68,7 @@ struct CIRcvDecryptionError: View { } } - @ViewBuilder private func viewBody() -> some View { + private func viewBody() -> some View { Group { if case let .direct(contact) = chat.chatInfo, let contactStats = contact.activeConn?.connectionStats { @@ -83,7 +83,7 @@ struct CIRcvDecryptionError: View { } else { basicDecryptionErrorItem() } - } else if case let .group(groupInfo) = chat.chatInfo, + } else if case let .group(groupInfo, _) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir, let mem = m.getGroupMember(groupMember.groupMemberId), let memberStats = mem.wrapped.activeConn?.connectionStats { @@ -133,7 +133,7 @@ struct CIRcvDecryptionError: View { CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) .padding(.horizontal, 12) } - .onTapGesture(perform: { onClick() }) + .simultaneousGesture(TapGesture().onEnded(onClick)) .padding(.vertical, 6) .textSelection(.disabled) } @@ -151,7 +151,7 @@ struct CIRcvDecryptionError: View { CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) .padding(.horizontal, 12) } - .onTapGesture(perform: { onClick() }) + .simultaneousGesture(TapGesture().onEnded(onClick)) .padding(.vertical, 6) .textSelection(.disabled) } @@ -161,13 +161,13 @@ struct CIRcvDecryptionError: View { let why = Text(decryptErrorReason) switch msgDecryptError { case .ratchetHeader: - message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why case .tooManySkipped: - message = Text("\(msgCount) messages skipped.") + Text("\n") + why + message = Text("\(msgCount) messages skipped.") + textNewLine + why case .ratchetEarlier: - message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why case .other: - message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why case .ratchetSync: message = Text("Encryption re-negotiation failed.") } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index f774299ad3..eacbe9360a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -47,57 +47,57 @@ struct CIVideoView: View { let file = chatItem.file ZStack(alignment: smallView ? .topLeading : .center) { ZStack(alignment: .topLeading) { - if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView { - smallVideoView(decrypted, file, preview) - } else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted { - videoView(player, decrypted, file, preview, duration) - } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView { - smallVideoViewEncrypted(file, defaultPreview) - } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { - videoViewEncrypted(file, defaultPreview, duration) - } else if let preview, let file { - Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } - .onTapGesture { - switch file.fileStatus { - case .rcvInvitation, .rcvAborted: - receiveFileIfValidSize(file: file, receiveFile: receiveFile) - case .rcvAccepted: - switch file.fileProtocol { - case .xftp: - AlertManager.shared.showAlertMsg( - title: "Waiting for video", - message: "Video will be received when your contact completes uploading it." - ) - case .smp: - AlertManager.shared.showAlertMsg( - title: "Waiting for video", - message: "Video will be received when your contact is online, please wait or check later!" - ) - case .local: () - } - case .rcvTransfer: () // ? - case .rcvComplete: () // ? - case .rcvCancelled: () // TODO - default: () - } + if let file, let preview { + if let urlDecrypted { + if smallView { + smallVideoView(urlDecrypted, file, preview) + } else if let player { + videoView(player, urlDecrypted, file, preview, duration) } + } else if file.loaded { + if smallView { + smallVideoViewEncrypted(file, preview) + } else { + videoViewEncrypted(file, preview, duration) + } + } else { + Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } + .simultaneousGesture(TapGesture().onEnded { + switch file.fileStatus { + case .rcvInvitation, .rcvAborted: + receiveFileIfValidSize(file: file, receiveFile: receiveFile) + case .rcvAccepted: + switch file.fileProtocol { + case .xftp: + AlertManager.shared.showAlertMsg( + title: "Waiting for video", + message: "Video will be received when your contact completes uploading it." + ) + case .smp: + AlertManager.shared.showAlertMsg( + title: "Waiting for video", + message: "Video will be received when your contact is online, please wait or check later!" + ) + case .local: () + } + case .rcvTransfer: () // ? + case .rcvComplete: () // ? + case .rcvCancelled: () // TODO + default: () + } + }) + } } if !smallView { durationProgress() } } if !blurred, let file, showDownloadButton(file.fileStatus) { - if !smallView { - Button { - receiveFileIfValidSize(file: file, receiveFile: receiveFile) - } label: { - playPauseIcon("play.fill") - } - } else if !file.showStatusIconInSmallView { + if !smallView || !file.showStatusIconInSmallView { playPauseIcon("play.fill") - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { receiveFileIfValidSize(file: file, receiveFile: receiveFile) - } + }) } } } @@ -151,27 +151,26 @@ struct CIVideoView: View { ZStack(alignment: .center) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) imageView(defaultPreview) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { decrypt(file: file) { showFullScreenPlayer = urlDecrypted != nil } - } + }) .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenPlayer = false } if !blurred { if !decryptionInProgress { - Button { - decrypt(file: file) { - if urlDecrypted != nil { - videoPlaying = true - player?.play() + playPauseIcon(canBePlayed ? "play.fill" : "play.slash") + .simultaneousGesture(TapGesture().onEnded { + decrypt(file: file) { + if urlDecrypted != nil { + videoPlaying = true + player?.play() + } } - } - } label: { - playPauseIcon(canBePlayed ? "play.fill" : "play.slash") - } - .disabled(!canBePlayed) + }) + .disabled(!canBePlayed) } else { videoDecryptionProgress() } @@ -194,29 +193,30 @@ struct CIVideoView: View { } } .modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) - .onTapGesture { - switch player.timeControlStatus { - case .playing: - player.pause() - videoPlaying = false - case .paused: - if canBePlayed { - showFullScreenPlayer = true + .if(!blurred) { v in + v.simultaneousGesture(TapGesture().onEnded { + switch player.timeControlStatus { + case .playing: + player.pause() + videoPlaying = false + case .paused: + if canBePlayed { + showFullScreenPlayer = true + } + default: () } - default: () - } + }) } .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenPlayer = false } if !videoPlaying && !blurred { - Button { - m.stopPreviousRecPlay = url - player.play() - } label: { - playPauseIcon(canBePlayed ? "play.fill" : "play.slash") - } - .disabled(!canBePlayed) + playPauseIcon(canBePlayed ? "play.fill" : "play.slash") + .simultaneousGesture(TapGesture().onEnded { + m.stopPreviousRecPlay = url + player.play() + }) + .disabled(!canBePlayed) } } fileStatusIcon() @@ -235,7 +235,7 @@ struct CIVideoView: View { return ZStack(alignment: .topLeading) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) smallViewImageView(preview, file) - .onTapGesture { + .onTapGesture { // this is shown in chat list, where onTapGesture works decrypt(file: file) { showFullScreenPlayer = urlDecrypted != nil } @@ -256,7 +256,7 @@ struct CIVideoView: View { private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View { return ZStack(alignment: .topLeading) { smallViewImageView(preview, file) - .onTapGesture { + .onTapGesture { // this is shown in chat list, where onTapGesture works showFullScreenPlayer = true } .onChange(of: m.activeCallViewIsCollapsed) { _ in @@ -354,14 +354,14 @@ struct CIVideoView: View { case .sndCancelled: fileIcon("xmark", 10, 13) case let .sndError(sndFileError): fileIcon("xmark", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError) - } + }) case let .sndWarning(sndFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) - } + }) case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case let .rcvTransfer(rcvProgress, rcvTotal): @@ -375,14 +375,14 @@ struct CIVideoView: View { case .rcvCancelled: fileIcon("xmark", 10, 13) case let .rcvError(rcvFileError): fileIcon("xmark", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError) - } + }) case let .rcvWarning(rcvFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError, temporary: true) - } + }) case .invalid: fileIcon("questionmark", 10, 13) } } @@ -429,7 +429,7 @@ struct CIVideoView: View { Color.black.edgesIgnoringSafeArea(.all) VideoPlayer(player: fullPlayer) .overlay(alignment: .topLeading, content: { - Button(action: { showFullScreenPlayer = false }, + Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here label: { Image(systemName: "multiply") .resizable() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index ff4378c715..47aee2a586 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -168,14 +168,14 @@ struct VoiceMessagePlayer: View { case .sndCancelled: playbackButton() case let .sndError(sndFileError): fileStatusIcon("multiply", 14) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError) - } + }) case let .sndWarning(sndFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) - } + }) case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvAccepted: loadingIcon() case .rcvTransfer: loadingIcon() @@ -184,14 +184,14 @@ struct VoiceMessagePlayer: View { case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case let .rcvError(rcvFileError): fileStatusIcon("multiply", 14) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError) - } + }) case let .rcvWarning(rcvFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError, temporary: true) - } + }) case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } } else { @@ -255,59 +255,29 @@ struct VoiceMessagePlayer: View { } } - @ViewBuilder private func playbackButton() -> some View { - if sizeMultiplier != 1 { - switch playbackState { - case .noPlayback: - playPauseIcon("play.fill", theme.colors.primary) - .onTapGesture { - if let recordingSource = getLoadedFileSource(recordingFile) { - startPlayback(recordingSource) - } - } - case .playing: - playPauseIcon("pause.fill", theme.colors.primary) - .onTapGesture { - audioPlayer?.pause() - playbackState = .paused - notifyStateChange() - } - case .paused: - playPauseIcon("play.fill", theme.colors.primary) - .onTapGesture { - audioPlayer?.play() - playbackState = .playing - notifyStateChange() - } - } - } else { - switch playbackState { - case .noPlayback: - Button { + private func playbackButton() -> some View { + let icon = switch playbackState { + case .noPlayback: "play.fill" + case .playing: "pause.fill" + case .paused: "play.fill" + } + return playPauseIcon(icon, theme.colors.primary) + .simultaneousGesture(TapGesture().onEnded { _ in + switch playbackState { + case .noPlayback: if let recordingSource = getLoadedFileSource(recordingFile) { startPlayback(recordingSource) } - } label: { - playPauseIcon("play.fill", theme.colors.primary) - } - case .playing: - Button { + case .playing: audioPlayer?.pause() playbackState = .paused notifyStateChange() - } label: { - playPauseIcon("pause.fill", theme.colors.primary) - } - case .paused: - Button { + case .paused: audioPlayer?.play() playbackState = .playing notifyStateChange() - } label: { - playPauseIcon("play.fill", theme.colors.primary) } - } - } + }) } private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View { @@ -329,28 +299,14 @@ struct VoiceMessagePlayer: View { } private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { - Group { - if sizeMultiplier != 1 { - playPauseIcon(icon, theme.colors.primary) - .onTapGesture { - Task { - if let user = chatModel.currentUser { - await receiveFile(user: user, fileId: recordingFile.fileId) - } - } + playPauseIcon(icon, theme.colors.primary) + .simultaneousGesture(TapGesture().onEnded { + Task { + if let user = chatModel.currentUser { + await receiveFile(user: user, fileId: recordingFile.fileId) } - } else { - Button { - Task { - if let user = chatModel.currentUser { - await receiveFile(user: user, fileId: recordingFile.fileId) - } - } - } label: { - playPauseIcon(icon, theme.colors.primary) } - } - } + }) } func notifyStateChange() { @@ -430,6 +386,7 @@ struct VoiceMessagePlayer: View { } } +@inline(__always) func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat { let squareToCircleRatio = 0.935 return squareSize + squareSize * (1 - squareToCircleRatio) @@ -446,10 +403,12 @@ class VoiceItemState { self.playbackTime = playbackTime } + @inline(__always) static func id(_ chat: Chat, _ chatItem: ChatItem) -> String { "\(chat.id) \(chatItem.id)" } + @inline(__always) static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String { "\(chatInfo.id) \(chatItem.id)" } @@ -476,6 +435,7 @@ class VoiceItemState { struct CIVoiceView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -498,10 +458,10 @@ struct CIVoiceView_Previews: PreviewProvider { duration: 30, allowMenu: Binding.constant(true) ) - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) } .previewLayout(.fixed(width: 360, height: 360)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 47a2cbb6cb..0b6f249b9c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -77,6 +77,7 @@ struct FramedCIVoiceView: View { struct FramedCIVoiceView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -92,11 +93,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10))) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 6da893d1d2..c9c9952688 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -12,9 +12,11 @@ import SimpleXChat struct FramedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme - @EnvironmentObject var scrollModel: ReverseListScrollModel @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem + var scrollToItem: (ChatItem.ID) -> Void + @Binding var scrollToItemId: ChatItem.ID? var preview: UIImage? var maxWidth: CGFloat = .infinity @State var msgWidth: CGFloat = 0 @@ -23,8 +25,6 @@ struct FramedItemView: View { @State private var useWhiteMetaColor: Bool = false @State var showFullScreenImage = false @Binding var allowMenu: Bool - @State private var showSecrets = false - @State private var showQuoteSecrets = false @State private var showFullscreenGallery: Bool = false var body: some View { @@ -57,18 +57,26 @@ struct FramedItemView: View { if let qi = chatItem.quotedItem { ciQuoteView(qi) - .onTapGesture { - if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { + .simultaneousGesture(TapGesture().onEnded { + if let ci = im.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { - scrollModel.scrollToItem(id: ci.id) + scrollToItem(ci.id) } + } else if let id = qi.itemId { + if (chatItem.isReport && im.secondaryIMFilter != nil) { + scrollToItemId = id + } else { + scrollToItem(id) + } + } else { + showQuotedItemDoesNotExistAlert() } - } + }) } else if let itemForwarded = chatItem.meta.itemForwarded { framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) } - ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: framedMsgContentView) + ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView) .padding(chatItem.content.msgContent != nil ? 0 : 4) .overlay(DetermineWidth()) } @@ -85,19 +93,19 @@ struct FramedItemView: View { .overlay(DetermineWidth()) .accessibilityLabel("") } - } + } .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) } .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } if let (title, text) = chatItem.meta.itemStatus.statusInfo { - v.onTapGesture { + v.simultaneousGesture(TapGesture().onEnded { AlertManager.shared.showAlert( Alert( title: Text(title), message: Text(text) ) ) - } + }) } else { v } @@ -117,7 +125,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -155,7 +163,7 @@ struct FramedItemView: View { case let .file(text): ciFileView(chatItem, text) case let .report(text, reason): - ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) + ciMsgContentView(chatItem, txtPrefix: reason.attrString) case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) @@ -199,6 +207,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View { + let backgroundColor = chatItemFrameContextColor(chatItem, theme) let v = ZStack(alignment: .topTrailing) { switch (qi.content) { case let .image(_, image): @@ -240,7 +249,8 @@ struct FramedItemView: View { // if enable this always, size of the framed voice message item will be incorrect after end of playback .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) - .background(chatItemFrameContextColor(chatItem, theme)) + .background(backgroundColor) + .environment(\.containerBackground, UIColor(backgroundColor)) if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { @@ -254,7 +264,7 @@ struct FramedItemView: View { VStack(alignment: .leading, spacing: 2) { Text(sender) .font(.caption) - .foregroundColor(theme.colors.secondary) + .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary) .lineLimit(1) ciQuotedMsgTextView(qi, lines: 2) } @@ -266,14 +276,12 @@ struct FramedItemView: View { .padding(.top, 6) .padding(.horizontal, 12) } - + + @inline(__always) private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { - toggleSecrets(qi.formattedText, $showQuoteSecrets, - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, showSecrets: showQuoteSecrets) - .lineLimit(lines) - .font(.subheadline) - .padding(.bottom, 6) - ) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline) + .lineLimit(lines) + .padding(.bottom, 6) } private func ciQuoteIconView(_ image: String) -> some View { @@ -288,24 +296,27 @@ struct FramedItemView: View { private func membership() -> GroupMember? { switch chat.chatInfo { - case let .group(groupInfo: groupInfo): return groupInfo.membership + case let .group(groupInfo: groupInfo, _): return groupInfo.membership default: return nil } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText - let v = toggleSecrets(ft, $showSecrets, MsgContentView( + let v = MsgContentView( chat: chat, text: text, formattedText: ft, + textStyle: .body, meta: ci.meta, + mentions: ci.mentions, + userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, - showSecrets: showSecrets, prefix: txtPrefix - )) + ) + .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) .padding(.horizontal, 12) @@ -336,13 +347,12 @@ struct FramedItemView: View { return videoWidth } } -} -@ViewBuilder func toggleSecrets(_ ft: [FormattedText]?, _ showSecrets: Binding, _ v: V) -> some View { - if let ft = ft, ft.contains(where: { $0.isSecret }) { - v.onTapGesture { showSecrets.wrappedValue.toggle() } - } else { - v + private func showQuotedItemDoesNotExistAlert() { + AlertManager.shared.showAlertMsg( + title: "No message", + message: "This message was deleted or not received yet." + ) } } @@ -382,15 +392,16 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color { struct FramedItemView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group{ - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -398,17 +409,18 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) @@ -417,17 +429,18 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index 044ee2a26d..f243a83142 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -13,8 +13,8 @@ import AVKit struct FullScreenMediaView: View { @EnvironmentObject var m: ChatModel - @EnvironmentObject var scrollModel: ReverseListScrollModel @State var chatItem: ChatItem + var scrollToItem: ((ChatItem.ID) -> Void)? @State var image: UIImage? @State var player: AVPlayer? = nil @State var url: URL? = nil @@ -71,7 +71,7 @@ struct FullScreenMediaView: View { let w = abs(t.width) if t.height > 60 && t.height > w * 2 { showView = false - scrollModel.scrollToItem(id: chatItem.id) + scrollToItem?(chatItem.id) } else if w > 60 && w > abs(t.height) * 2 && !scrolling { let previous = t.width > 0 scrolling = true @@ -126,7 +126,7 @@ struct FullScreenMediaView: View { .scaledToFit() } } - .onTapGesture { showView = false } + .onTapGesture { showView = false } // this is used in full screen view, onTapGesture works } private func videoView( _ player: AVPlayer, _ url: URL) -> some View { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index afeb88b05d..47a30f6cf3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -31,8 +31,8 @@ struct IntegrityErrorItemView: View { case .msgBadHash: AlertManager.shared.showAlert(Alert( title: Text("Bad message hash"), - message: Text("The hash of the previous message is different.") + Text("\n") + - Text(decryptErrorReason) + Text("\n") + + message: Text("The hash of the previous message is different.") + textNewLine + + Text(decryptErrorReason) + textNewLine + Text("Please report it to the developers.") )) case .msgBadId: msgBadIdAlert() @@ -47,7 +47,7 @@ struct IntegrityErrorItemView: View { message: Text(""" The ID of the next message is incorrect (less or equal to the previous). It can happen because of some bug or when the connection is compromised. - """) + Text("\n") + + """) + textNewLine + Text("Please report it to the developers.") )) } @@ -71,7 +71,7 @@ struct CIMsgError: View { .padding(.vertical, 6) .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .textSelection(.disabled) - .onTapGesture(perform: onTap) + .simultaneousGesture(TapGesture().onEnded(onTap)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 87a9b2ce61..c6a5d0353c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -14,6 +14,7 @@ struct MarkedDeletedItemView: View { @EnvironmentObject var theme: AppTheme @Environment(\.revealed) var revealed: Bool @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var body: some View { @@ -29,14 +30,14 @@ struct MarkedDeletedItemView: View { var mergedMarkedDeletedText: LocalizedStringKey { if !revealed, let ciCategory = chatItem.mergeCategory, - var i = m.getChatItemIndex(chatItem) { + var i = m.getChatItemIndex(im, chatItem) { var moderated = 0 var blocked = 0 var blockedByAdmin = 0 var deleted = 0 var moderatedBy: Set = [] - while i < ItemsModel.shared.reversedChatItems.count, - let ci = .some(ItemsModel.shared.reversedChatItems[i]), + while i < im.reversedChatItems.count, + let ci = .some(im.reversedChatItems[i]), ci.mergeCategory == ciCategory, let itemDeleted = ci.meta.itemDeleted { switch itemDeleted { @@ -85,6 +86,7 @@ struct MarkedDeletedItemView_Previews: PreviewProvider { Group { MarkedDeletedItemView( chat: Chat.sampleData, + im: ItemsModel.shared, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)) ).environment(\.revealed, true) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index e9b6d0ba84..e04584dfff 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -11,51 +11,74 @@ import SimpleXChat let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) -private let noTyping = Text(verbatim: " ") - -private let typingIndicators: [Text] = [ - (typing(.black) + typing() + typing()), - (typing(.bold) + typing(.black) + typing()), - (typing() + typing(.bold) + typing(.black)), - (typing() + typing() + typing(.bold)) -] - -private func typing(_ w: Font.Weight = .light) -> Text { - Text(".").fontWeight(w) +private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.Weight]) -> NSMutableAttributedString { + let res = NSMutableAttributedString() + for w in ws { + res.append(NSAttributedString(string: ".", attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: w), + .kern: -2 as NSNumber, + .foregroundColor: UIColor(theme.colors.secondary) + ])) + } + return res } struct MsgContentView: View { @ObservedObject var chat: Chat @Environment(\.showTimestamp) var showTimestamp: Bool + @Environment(\.containerBackground) var containerBackground: UIColor @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil + var textStyle: UIFont.TextStyle var sender: String? = nil var meta: CIMeta? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil var rightToLeft = false - var showSecrets: Bool - var prefix: Text? = nil + var prefix: NSAttributedString? = nil + @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? + @State private var typingIndicators: [NSAttributedString] = [] + @State private var noTyping = NSAttributedString(string: " ") + @State private var phase: CGFloat = 0 @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false var body: some View { + let v = msgContentView() if meta?.isLive == true { - msgContentView() - .onAppear { switchTyping() } + v.onAppear { + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + noTyping = NSAttributedString(string: " ", attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular), + .kern: -2 as NSNumber, + .foregroundColor: UIColor(theme.colors.secondary) + ]) + switchTyping() + } .onDisappear(perform: stopTyping) .onChange(of: meta?.isLive, perform: switchTyping) .onChange(of: meta?.recent, perform: switchTyping) } else { - msgContentView() + v } } private func switchTyping(_: Bool? = nil) { if let meta = meta, meta.isLive && meta.recent { + if typingIndicators.isEmpty { + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + typingIndicators = [ + typing(theme, descr, [.black, .light, .light]), + typing(theme, descr, [.bold, .black, .light]), + typing(theme, descr, [.light, .bold, .black]), + typing(theme, descr, [.light, .light, .bold]) + ] + } timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in - typingIdx = (typingIdx + 1) % typingIndicators.count + typingIdx = typingIdx + 1 } } else { stopTyping() @@ -65,100 +88,276 @@ struct MsgContentView: View { private func stopTyping() { timer?.invalidate() timer = nil + typingIdx = 0 } - private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) + @inline(__always) + private func msgContentView() -> some View { + let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix) + let s = r.string + let t: Text if let mt = meta { if mt.isLive { - v = v + typingIndicator(mt.recent) + s.append(typingIndicator(mt.recent)) } - v = v + reserveSpaceForMeta(mt) + t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) + } else { + t = Text(AttributedString(s)) } - return v + return msgTextResultView(r, t, showSecrets: $showSecrets) } - private func typingIndicator(_ recent: Bool) -> Text { - return (recent ? typingIndicators[typingIdx] : noTyping) - .font(.body.monospaced()) - .kerning(-2) - .foregroundColor(theme.colors.secondary) + @inline(__always) + private func typingIndicator(_ recent: Bool) -> NSAttributedString { + recent && !typingIndicators.isEmpty + ? typingIndicators[typingIdx % 4] + : noTyping } + @inline(__always) private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? Text("\n") : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { - let s = text - var res: Text - - if let ft = formattedText, ft.count > 0 && ft.count <= 200 { - res = formatText(ft[0], preview, showSecret: showSecrets) - var i = 1 - while i < ft.count { - res = res + formatText(ft[i], preview, showSecret: showSecrets) - i = i + 1 - } - } else { - res = Text(s) - } - - if let i = icon { - res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res - } - - if let p = prefix { - res = p + res - } - - if let s = sender { - let t = Text(s) - return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res - } else { - return res - } +func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding>? = nil) -> some View { + t.if(r.hasSecrets, transform: hiddenSecretsView) + .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) } } -private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text { - let t = ft.text - if let f = ft.format { - switch (f) { - case .bold: return Text(t).bold() - case .italic: return Text(t).italic() - case .strikeThrough: return Text(t).strikethrough() - case .snippet: return Text(t).font(.body.monospaced()) - case .secret: return - showSecret - ? Text(t) - : Text(AttributedString(t, attributes: AttributeContainer([ - .foregroundColor: UIColor.clear as Any, - .backgroundColor: UIColor.secondarySystemFill as Any - ]))) - case let .colored(color): return Text(t).foregroundColor(color.uiColor) - case .uri: return linkText(t, t, preview, prefix: "") - case let .simplexLink(linkType, simplexUri, smpHosts): - switch privacySimplexLinkModeDefault.get() { - case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") - case .full: return linkText(t, simplexUri, preview, prefix: "") - case .browser: return linkText(t, simplexUri, preview, prefix: "") +@inline(__always) +private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding>? = nil) -> some View { + return GeometryReader { g in + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { event in + let t = event.translation + if t.width * t.width + t.height * t.height > 100 { return } + let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString) + let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil) + let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil) + let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit + var index: CFIndex? + if let lines = CTFrameGetLines(frame) as? [CTLine] { + var origins = [CGPoint](repeating: .zero, count: lines.count) + CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) + for i in 0 ..< lines.count { + let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds) + if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) { + index = CTLineGetStringIndexForPosition(lines[i], point) + break + } + } + } + if let index, let (url, browser) = attributedStringLink(s, for: index) { + if browser { + openBrowserAlert(uri: url) + } else { + UIApplication.shared.open(url) + } + } + }) + } + + func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? { + var linkURL: URL? + var browser: Bool = false + s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in + if index >= range.location && index < range.location + range.length { + if let url = attrs[linkAttrKey] as? NSURL { + linkURL = url.absoluteURL + browser = attrs[webLinkAttrKey] != nil + } else if let showSecrets, let i = attrs[secretAttrKey] as? Int { + if showSecrets.wrappedValue.contains(i) { + showSecrets.wrappedValue.remove(i) + } else { + showSecrets.wrappedValue.insert(i) + } + } + stop.pointee = true } - case .email: return linkText(t, t, preview, prefix: "mailto:") - case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") } - } else { - return Text(t) + return if let linkURL { (linkURL, browser) } else { nil } } } -private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text { - preview - ? Text(s).foregroundColor(color).underline(color: color) - : Text(AttributedString(s, attributes: AttributeContainer([ - .link: NSURL(string: prefix + link) as Any, - .foregroundColor: uiColor as Any - ]))).underline() +func hiddenSecretsView(_ v: V) -> some View { + v.overlay( + GeometryReader { g in + let size = (g.size.width + g.size.height) / 1.4142 + Image("vertical_logo") + .resizable(resizingMode: .tile) + .frame(width: size, height: size) + .rotationEffect(.degrees(45), anchor: .center) + .position(x: g.size.width / 2, y: g.size.height / 2) + .clipped() + .saturation(0.65) + .opacity(0.35) + } + .mask(v) + ) +} + +private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link") + +private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink") + +private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") + +typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool) + +func messageText( + _ text: String, + _ formattedText: [FormattedText]?, + textStyle: UIFont.TextStyle = .body, + sender: String?, + preview: Bool = false, + mentions: [String: CIMention]?, + userMemberId: String?, + showSecrets: Set?, + backgroundColor: UIColor, + prefix: NSAttributedString? = nil +) -> MsgTextResult { + let res = NSMutableAttributedString() + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) + let font = UIFont.preferredFont(forTextStyle: textStyle) + let plain: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.label + ] + let secretColor = backgroundColor.withAlphaComponent(1) + var link: [NSAttributedString.Key: Any]? + var hasSecrets = false + var handleTaps = false + + if let sender { + if preview { + res.append(NSAttributedString(string: sender + ": ", attributes: plain)) + } else { + var attrs = plain + attrs[.font] = UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.medium]]), size: descr.pointSize) + res.append(NSAttributedString(string: sender, attributes: attrs)) + res.append(NSAttributedString(string: ": ", attributes: plain)) + } + } + + if let prefix { + res.append(prefix) + } + + if let fts = formattedText, fts.count > 0 { + var bold: UIFont? + var italic: UIFont? + var snippet: UIFont? + var mention: UIFont? + var secretIdx: Int = 0 + for ft in fts { + var t = ft.text + var attrs = plain + switch (ft.format) { + case .bold: + bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) + attrs[.font] = bold + case .italic: + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + case .strikeThrough: + attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + case .snippet: + snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular) + attrs[.font] = snippet + case .secret: + if let showSecrets { + if !showSecrets.contains(secretIdx) { + attrs[.foregroundColor] = UIColor.clear + attrs[.backgroundColor] = secretColor + } + attrs[secretAttrKey] = secretIdx + secretIdx += 1 + handleTaps = true + } else { + attrs[.foregroundColor] = UIColor.clear + attrs[.backgroundColor] = secretColor + } + hasSecrets = true + case let .colored(color): + if let c = color.uiColor { + attrs[.foregroundColor] = UIColor(c) + } + case .uri: + attrs = linkAttrs() + if !preview { + let s = t.lowercased() + let link = s.hasPrefix("http://") || s.hasPrefix("https://") + ? t + : "https://" + t + attrs[linkAttrKey] = NSURL(string: link) + attrs[webLinkAttrKey] = true + handleTaps = true + } + case let .simplexLink(linkType, simplexUri, smpHosts): + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = NSURL(string: simplexUri) + handleTaps = true + } + if case .description = privacySimplexLinkModeDefault.get() { + t = simplexLinkText(linkType, smpHosts) + } + case let .mention(memberName): + if let m = mentions?[memberName] { + mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize) + attrs[.font] = mention + if let ref = m.memberRef { + let name: String = if let alias = ref.localAlias, alias != "" { + "\(alias) (\(ref.displayName))" + } else { + ref.displayName + } + if m.memberId == userMemberId { + attrs[.foregroundColor] = UIColor.tintColor + } + t = mentionText(name) + } else { + t = mentionText(memberName) + } + } + case .email: + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text) + handleTaps = true + } + case .phone: + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: "")) + handleTaps = true + } + case .none: () + } + res.append(NSAttributedString(string: t, attributes: attrs)) + } + } else { + res.append(NSMutableAttributedString(string: text, attributes: plain)) + } + + return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps) + + func linkAttrs() -> [NSAttributedString.Key: Any] { + link = link ?? [ + .font: font, + .foregroundColor: uiLinkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return link! + } +} + +@inline(__always) +private func mentionText(_ name: String) -> String { + name.contains(" @") ? "@'\(name)'" : "@\(name)" } func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { @@ -172,9 +371,9 @@ struct MsgContentView_Previews: PreviewProvider { chat: Chat.sampleData, text: chatItem.text, formattedText: chatItem.formattedText, + textStyle: .body, sender: chatItem.memberDisplayName, - meta: chatItem.meta, - showSecrets: false + meta: chatItem.meta ) .environmentObject(Chat.sampleData) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index 587957cd5d..dfc620c402 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -41,7 +41,7 @@ struct ChatItemForwardingView: View { .alert(item: $alert) { $0.alert } } - @ViewBuilder private func forwardListView() -> some View { + private func forwardListView() -> some View { VStack(alignment: .leading) { if !chatsToForwardTo.isEmpty { List { diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 62ea607d27..cd75d1b0cd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -14,6 +14,7 @@ struct ChatItemInfoView: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme var ci: ChatItem + var userMemberId: String? @Binding var chatItemInfo: ChatItemInfo? @State private var selection: CIInfoTab = .history @State private var alert: CIInfoViewAlert? = nil @@ -130,9 +131,9 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func details() -> some View { + private func details() -> some View { let meta = ci.meta - VStack(alignment: .leading, spacing: 16) { + return VStack(alignment: .leading, spacing: 16) { Text(title) .font(.largeTitle) .bold() @@ -196,7 +197,7 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func historyTab() -> some View { + private func historyTab() -> some View { GeometryReader { g in let maxWidth = (g.size.width - 32) * 0.84 ScrollView { @@ -226,12 +227,13 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { - VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil) + private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { + let backgroundColor = chatItemFrameColor(ci, theme) + return VStack(alignment: .leading, spacing: 4) { + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: UIColor(backgroundColor)) .padding(.horizontal, 12) .padding(.vertical, 6) - .background(chatItemFrameColor(ci, theme)) + .background(backgroundColor) .modifier(ChatItemClipped()) .contextMenu { if itemVersion.msgContent.text != "" { @@ -256,9 +258,9 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender) + TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) } else { Text("no text") .italic() @@ -271,14 +273,18 @@ struct ChatItemInfoView: View { var text: String var formattedText: [FormattedText]? var sender: String? = nil - @State private var showSecrets = false + var mentions: [String: CIMention]? + var userMemberId: String? + var backgroundColor: UIColor + @State private var showSecrets: Set = [] var body: some View { - toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor) + return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) } } - @ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View { + private func quoteTab(_ qi: CIQuote) -> some View { GeometryReader { g in let maxWidth = (g.size.width - 32) * 0.84 ScrollView { @@ -296,9 +302,10 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { - VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil)) + private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { + let backgroundColor = quotedMsgFrameColor(qi, theme) + return VStack(alignment: .leading, spacing: 4) { + textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: UIColor(backgroundColor)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) @@ -331,7 +338,7 @@ struct ChatItemInfoView: View { : theme.appColors.receivedMessage } - @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { + private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { ScrollView { VStack(alignment: .leading, spacing: 16) { details() @@ -351,8 +358,9 @@ struct ChatItemInfoView: View { Button { Task { await MainActor.run { - ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) - dismiss() + ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) { + dismiss() + } } } } label: { @@ -368,7 +376,7 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { + private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { HStack { ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48) .padding(.trailing, 6) @@ -399,7 +407,7 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { + private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { ScrollView { VStack(alignment: .leading, spacing: 16) { details() @@ -414,7 +422,7 @@ struct ChatItemInfoView: View { .frame(maxHeight: .infinity, alignment: .top) } - @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { + private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { LazyVStack(alignment: .leading, spacing: 12) { let mss = membersStatuses(memberDeliveryStatuses) if !mss.isEmpty { @@ -548,6 +556,6 @@ func localTimestamp(_ date: Date) -> String { struct ChatItemInfoView_Previews: PreviewProvider { static var previews: some View { - ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil)) + ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index ebbc55a932..a412bf4452 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -18,6 +18,10 @@ extension EnvironmentValues { static let defaultValue: Bool = true } + struct ContainerBackground: EnvironmentKey { + static let defaultValue: UIColor = .clear + } + var showTimestamp: Bool { get { self[ShowTimestamp.self] } set { self[ShowTimestamp.self] = newValue } @@ -27,26 +31,40 @@ extension EnvironmentValues { get { self[Revealed.self] } set { self[Revealed.self] = newValue } } + + var containerBackground: UIColor { + get { self[ContainerBackground.self] } + set { self[ContainerBackground.self] = newValue } + } } struct ChatItemView: View { @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @EnvironmentObject var theme: AppTheme @Environment(\.showTimestamp) var showTimestamp: Bool @Environment(\.revealed) var revealed: Bool var chatItem: ChatItem + var scrollToItem: (ChatItem.ID) -> Void + @Binding var scrollToItemId: ChatItem.ID? var maxWidth: CGFloat = .infinity @Binding var allowMenu: Bool init( chat: Chat, + im: ItemsModel, chatItem: ChatItem, + scrollToItem: @escaping (ChatItem.ID) -> Void, + scrollToItemId: Binding = .constant(nil), showMember: Bool = false, maxWidth: CGFloat = .infinity, allowMenu: Binding = .constant(false) ) { self.chat = chat + self.im = im self.chatItem = chatItem + self.scrollToItem = scrollToItem + _scrollToItemId = scrollToItemId self.maxWidth = maxWidth _allowMenu = allowMenu } @@ -54,14 +72,14 @@ struct ChatItemView: View { var body: some View { let ci = chatItem if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { - MarkedDeletedItemView(chat: chat, chatItem: chatItem) + MarkedDeletedItemView(chat: chat, im: im, chatItem: chatItem) } else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { EmojiItemView(chat: chat, chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu) } else if ci.content.msgContent == nil { - ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case + ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case } else { framedItemView() } @@ -89,7 +107,10 @@ struct ChatItemView: View { }() return FramedItemView( chat: chat, + im: im, chatItem: chatItem, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: adjustedMaxWidth, @@ -104,6 +125,7 @@ struct ChatItemContentView: View { @EnvironmentObject var theme: AppTheme @Environment(\.revealed) var revealed: Bool @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var msgContentView: () -> Content @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -127,7 +149,9 @@ struct ChatItemContentView: View { case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case .rcvDirectEvent: eventItemView() case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) + case .rcvGroupEvent(.newMemberPendingReview): CIEventView(eventText: pendingReviewEventItemText()) case .rcvGroupEvent: eventItemView() + case .sndGroupEvent(.userPendingReview): CIEventView(eventText: pendingReviewEventItemText()) case .sndGroupEvent: eventItemView() case .rcvConnEvent: eventItemView() case .sndConnEvent: eventItemView() @@ -136,7 +160,7 @@ struct ChatItemContentView: View { case let .rcvChatPreference(feature, allowed, param): CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) + CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) @@ -168,6 +192,13 @@ struct ChatItemContentView: View { CIEventView(eventText: eventItemViewText(theme.colors.secondary)) } + private func pendingReviewEventItemText() -> Text { + Text(chatItem.content.text) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .fontWeight(.bold) + } + private func eventItemViewText(_ secondaryColor: Color) -> Text { if !revealed, let t = mergedGroupEventText { return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor) @@ -183,7 +214,7 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor) + CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, iconColor: iconColor) } private var mergedGroupEventText: Text? { @@ -243,16 +274,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group{ - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 70)) @@ -262,57 +294,72 @@ struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)), quotedItem: nil, file: nil - ) + ), + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2), quotedItem: nil, file: nil - ) + ), + scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin), quotedItem: nil, file: nil - ) + ), + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)), quotedItem: nil, file: nil - ) + ), + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), content: ciFeatureContent, quotedItem: nil, file: nil - ) + ), + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) } .environment(\.revealed, true) diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift new file mode 100644 index 0000000000..93ecf870eb --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -0,0 +1,516 @@ +// +// ChatItemsLoader.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 17.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SimpleXChat +import SwiftUI + +let TRIM_KEEP_COUNT = 200 + +func apiLoadMessages( + _ chatId: ChatId, + _ im: ItemsModel, + _ pagination: ChatPagination, + _ search: String = "", + _ openAroundItemId: ChatItem.ID? = nil, + _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } +) async { + let chat: Chat + let navInfo: NavigationInfo + do { + (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search) + } catch let error { + logger.error("apiLoadMessages error: \(responseError(error))") + return + } + + let chatModel = ChatModel.shared + + // 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 + let paginationIsInitial = switch pagination { case .initial: true; default: false } + let paginationIsLast = switch pagination { case .last: true; default: false } + // When openAroundItemId is provided, chatId can be different too + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled { + return + } + + let unreadAfterItemId = im.chatState.unreadAfterItemId + + let oldItems = Array(im.reversedChatItems.reversed()) + var newItems: [ChatItem] = [] + switch pagination { + case .initial: + let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] } + if im.secondaryIMFilter == nil && chatModel.getChat(chat.id) == nil { + chatModel.addChat(chat) + } + await MainActor.run { + im.reversedChatItems = chat.chatItems.reversed() + if im.secondaryIMFilter == nil { + chatModel.updateChatInfo(chat.chatInfo) + } + im.chatState.splits = newSplits + if !chat.chatItems.isEmpty { + im.chatState.unreadAfterItemId = chat.chatItems.last!.id + } + im.chatState.totalAfter = navInfo.afterTotal + im.chatState.unreadTotal = chat.chatStats.unreadCount + im.chatState.unreadAfter = navInfo.afterUnread + im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread + + im.preloadState.clear() + } + case let .before(paginationChatItemId, _): + newItems.append(contentsOf: oldItems) + let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId }) + guard let indexInCurrentItems else { return } + let (newIds, _) = mapItemsToIds(chat.chatItems) + let wasSize = newItems.count + let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() } + let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination( + unreadAfterItemId, &newItems, newIds, im.chatState.splits, visibleItemIndexes + ) + let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0) + newItems.insert(contentsOf: chat.chatItems, at: insertAt) + let newReversed: [ChatItem] = newItems.reversed() + await MainActor.run { + im.reversedChatItems = newReversed + im.chatState.splits = modifiedSplits.newSplits + im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) + } + case let .after(paginationChatItemId, _): + newItems.append(contentsOf: oldItems) + let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId }) + guard let indexInCurrentItems else { return } + + let mappedItems = mapItemsToIds(chat.chatItems) + let newIds = mappedItems.0 + let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination( + mappedItems.1, paginationChatItemId, &newItems, newIds, chat, im.chatState.splits + ) + let indexToAdd = min(indexInCurrentItems + 1, newItems.count) + let indexToAddIsLast = indexToAdd == newItems.count + newItems.insert(contentsOf: chat.chatItems, at: indexToAdd) + let new: [ChatItem] = newItems + let newReversed: [ChatItem] = newItems.reversed() + await MainActor.run { + im.reversedChatItems = newReversed + im.chatState.splits = newSplits + im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new) + // loading clear bottom area, updating number of unread items after the newest loaded item + if indexToAddIsLast { + im.chatState.unreadAfterNewestLoaded -= unreadInLoaded + } + } + case .around: + var newSplits: [Int64] + if openAroundItemId == nil { + newItems.append(contentsOf: oldItems) + newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, im.chatState.splits, visibleItemIndexesNonReversed) + } else { + newSplits = [] + } + let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits)) + //indexToInsertAroundTest() + newItems.insert(contentsOf: chat.chatItems, at: itemIndex) + newSplits.insert(chat.chatItems.last!.id, at: splitIndex) + let newReversed: [ChatItem] = newItems.reversed() + let orderedSplits = newSplits + await MainActor.run { + im.reversedChatItems = newReversed + im.chatState.splits = orderedSplits + im.chatState.unreadAfterItemId = chat.chatItems.last!.id + im.chatState.totalAfter = navInfo.afterTotal + im.chatState.unreadTotal = chat.chatStats.unreadCount + im.chatState.unreadAfter = navInfo.afterUnread + + if let openAroundItemId { + im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread + if im.secondaryIMFilter == nil { + ChatModel.shared.openAroundItemId = openAroundItemId // TODO [knocking] move openAroundItemId from ChatModel to ItemsModel? + ChatModel.shared.chatId = chat.id + } + } else { + // no need to set it, count will be wrong + // chatState.unreadAfterNewestLoaded = navInfo.afterUnread + } + im.preloadState.clear() + } + case .last: + newItems.append(contentsOf: oldItems) + let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, im.chatState.splits) + newItems.append(contentsOf: chat.chatItems) + let items = newItems + await MainActor.run { + im.reversedChatItems = items.reversed() + im.chatState.splits = newSplits + if im.secondaryIMFilter == nil { + chatModel.updateChatInfo(chat.chatInfo) + } + im.chatState.unreadAfterNewestLoaded = 0 + } + } +} + + +private class ModifiedSplits { + let oldUnreadSplitIndex: Int + let newUnreadSplitIndex: Int + let trimmedIds: Set + let newSplits: [Int64] + + init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set, newSplits: [Int64]) { + self.oldUnreadSplitIndex = oldUnreadSplitIndex + self.newUnreadSplitIndex = newUnreadSplitIndex + self.trimmedIds = trimmedIds + self.newSplits = newSplits + } +} + +private func removeDuplicatesAndModifySplitsOnBeforePagination( + _ unreadAfterItemId: Int64, + _ newItems: inout [ChatItem], + _ newIds: Set, + _ splits: [Int64], + _ visibleItemIndexes: ClosedRange +) -> ModifiedSplits { + var oldUnreadSplitIndex: Int = -1 + var newUnreadSplitIndex: Int = -1 + var lastSplitIndexTrimmed: Int? = nil + var allowedTrimming = true + var index = 0 + /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ + let trimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + let trimUpperBound = newItems.count - TRIM_KEEP_COUNT + let trimRange = trimUpperBound >= trimLowerBound ? trimLowerBound ... trimUpperBound : -1 ... -1 + var trimmedIds = Set() + let prevTrimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1 + let prevTrimUpperBound = newItems.count - TRIM_KEEP_COUNT + let prevItemTrimRange = prevTrimUpperBound >= prevTrimLowerBound ? prevTrimLowerBound ... prevTrimUpperBound : -1 ... -1 + var newSplits = splits + + newItems.removeAll(where: { + let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming + let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming + // may disable it after clearing the whole split range + if !splits.isEmpty && $0.id == splits.first { + // trim only in one split range + allowedTrimming = false + } + let indexInSplits = splits.firstIndex(of: $0.id) + if let indexInSplits { + lastSplitIndexTrimmed = indexInSplits + } + if invisibleItemToTrim { + if prevItemWasTrimmed { + trimmedIds.insert($0.id) + } else { + newUnreadSplitIndex = index + // prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead. + // this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction + if let lastSplitIndexTrimmed { + var new = newSplits + new[lastSplitIndexTrimmed] = $0.id + newSplits = new + } else { + newSplits = [$0.id] + newSplits + } + } + } + if unreadAfterItemId == $0.id { + oldUnreadSplitIndex = index + } + index += 1 + return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id) + }) + // will remove any splits that now becomes obsolete because items were merged + newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) } + return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits) +} + +private func removeDuplicatesAndModifySplitsOnAfterPagination( + _ unreadInLoaded: Int, + _ paginationChatItemId: Int64, + _ newItems: inout [ChatItem], + _ newIds: Set, + _ chat: Chat, + _ splits: [Int64] +) -> ([Int64], Int) { + var unreadInLoaded = unreadInLoaded + var firstItemIdBelowAllSplits: Int64? = nil + var splitsToRemove: Set = [] + let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId) + // Currently, it should always load from split range + let loadingFromSplitRange = indexInSplitRanges != nil + let topSplits: [Int64] + var splitsToMerge: [Int64] + if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count { + splitsToMerge = Array(splits[indexInSplitRanges + 1 ..< splits.count]) + topSplits = Array(splits[0 ..< indexInSplitRanges + 1]) + } else { + splitsToMerge = [] + topSplits = [] + } + newItems.removeAll(where: { new in + let duplicate = newIds.contains(new.id) + if loadingFromSplitRange && duplicate { + if splitsToMerge.contains(new.id) { + splitsToMerge.removeAll(where: { $0 == new.id }) + splitsToRemove.insert(new.id) + } else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty { + // we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items + firstItemIdBelowAllSplits = new.id + } + } + if duplicate && new.isRcvNew { + unreadInLoaded -= 1 + } + return duplicate + }) + var newSplits: [Int64] = [] + if firstItemIdBelowAllSplits != nil { + // no splits below anymore, all were merged with bottom items + newSplits = topSplits + } else { + if !splitsToRemove.isEmpty { + var new = splits + new.removeAll(where: { splitsToRemove.contains($0) }) + newSplits = new + } + let enlargedSplit = splits.firstIndex(of: paginationChatItemId) + if let enlargedSplit { + // move the split to the end of loaded items + var new = splits + new[enlargedSplit] = chat.chatItems.last!.id + newSplits = new + } + } + return (newSplits, unreadInLoaded) +} + +private func removeDuplicatesAndUpperSplits( + _ newItems: inout [ChatItem], + _ chat: Chat, + _ splits: [Int64], + _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange +) async -> [Int64] { + if splits.isEmpty { + removeDuplicates(&newItems, chat) + return splits + } + + var newSplits = splits + let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() } + let (newIds, _) = mapItemsToIds(chat.chatItems) + var idsToTrim: [BoxedValue>] = [] + idsToTrim.append(BoxedValue(Set())) + var index = 0 + newItems.removeAll(where: { + let duplicate = newIds.contains($0.id) + if (!duplicate && visibleItemIndexes.lowerBound > index) { + idsToTrim.last?.boxedValue.insert($0.id) + } + if visibleItemIndexes.lowerBound > index, let firstIndex = newSplits.firstIndex(of: $0.id) { + newSplits.remove(at: firstIndex) + // closing previous range. All items in idsToTrim that ends with empty set should be deleted. + // Otherwise, the last set should be excluded from trimming because it is in currently visible split range + idsToTrim.append(BoxedValue(Set())) + } + + index += 1 + return duplicate + }) + if !idsToTrim.last!.boxedValue.isEmpty { + // it has some elements to trim from currently visible range which means the items shouldn't be trimmed + // Otherwise, the last set would be empty + idsToTrim.removeLast() + } + let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined() + if !allItemsToDelete.isEmpty { + newItems.removeAll(where: { allItemsToDelete.contains($0.id) }) + } + return newSplits +} + +private func removeDuplicatesAndUnusedSplits( + _ newItems: inout [ChatItem], + _ chat: Chat, + _ splits: [Int64] +) async -> [Int64] { + if splits.isEmpty { + removeDuplicates(&newItems, chat) + return splits + } + + var newSplits = splits + let (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll(where: { + let duplicate = newIds.contains($0.id) + if duplicate, let firstIndex = newSplits.firstIndex(of: $0.id) { + newSplits.remove(at: firstIndex) + } + return duplicate + }) + return newSplits +} + +// ids, number of unread items +private func mapItemsToIds(_ items: [ChatItem]) -> (Set, Int) { + var unreadInLoaded = 0 + var ids: Set = Set() + var i = 0 + while i < items.count { + let item = items[i] + ids.insert(item.id) + if item.isRcvNew { + unreadInLoaded += 1 + } + i += 1 + } + return (ids, unreadInLoaded) +} + +private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) { + let (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll { newIds.contains($0.id) } +} + +private typealias SameTimeItem = (index: Int, item: ChatItem) + +// return (item index, split index) +private func indexToInsertAround(_ chatType: ChatType, _ lastNew: ChatItem?, to: [ChatItem], _ splits: Set) -> (Int, Int) { + guard to.count > 0, let lastNew = lastNew else { return (0, 0) } + // group sorting: item_ts, item_id + // everything else: created_at, item_id + let compareByTimeTs = chatType == .group + // in case several items have the same time as another item in the `to` array + var sameTime: [SameTimeItem] = [] + + // trying to find new split index for item looks difficult but allows to not use one more loop. + // The idea is to memorize how many splits were till any index (map number of splits until index) + // and use resulting itemIndex to decide new split index position. + // Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`. + var splitsTillIndex: [Int] = [] + var splitsPerPrevIndex = 0 + + for i in 0 ..< to.count { + let item = to[i] + + splitsPerPrevIndex = splits.contains(item.id) ? splitsPerPrevIndex + 1 : splitsPerPrevIndex + splitsTillIndex.append(splitsPerPrevIndex) + + let itemIsNewer = (compareByTimeTs ? item.meta.itemTs > lastNew.meta.itemTs : item.meta.createdAt > lastNew.meta.createdAt) + if itemIsNewer || i + 1 == to.count { + if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) { + sameTime.append((i, item)) + } + // time to stop the loop. Item is newer or it's the last item in `to` array, taking previous items and checking position inside them + let itemIndex: Int + if sameTime.count > 1, let first = sameTime.sorted(by: { prev, next in prev.item.meta.itemId < next.item.id }).first(where: { same in same.item.id > lastNew.id }) { + itemIndex = first.index + } else if sameTime.count == 1 { + itemIndex = sameTime[0].item.id > lastNew.id ? sameTime[0].index : sameTime[0].index + 1 + } else { + itemIndex = itemIsNewer ? i : i + 1 + } + let splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.count - 1)] + let prevItemSplitIndex = itemIndex == 0 ? 0 : splitsTillIndex[min(itemIndex - 1, splitsTillIndex.count - 1)] + return (itemIndex, splitIndex == prevItemSplitIndex ? splitIndex : prevItemSplitIndex) + } + + if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) { + sameTime.append(SameTimeItem(index: i, item: item)) + } else { + sameTime = [] + } + } + // shouldn't be here + return (to.count, splits.count) +} + +private func indexToInsertAroundTest() { + func assert(_ one: (Int, Int), _ two: (Int, Int)) { + if one != two { + logger.debug("\(String(describing: one)) != \(String(describing: two))") + fatalError() + } + } + + let itemsToInsert = [ChatItem.getSample(3, .groupSnd, Date.init(timeIntervalSince1970: 3), "")] + let items1 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 2), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items1, Set([1])), (3, 1)) + + let items2 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items2, Set([2])), (3, 1)) + + let items3 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items3, Set([1])), (3, 1)) + + let items4 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items4, Set([4])), (1, 0)) + + let items5 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items5, Set([2])), (2, 1)) + + let items6 = [ + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items6, Set([5])), (0, 0)) + + let items7 = [ + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, nil, to: items7, Set([6])), (0, 0)) + + let items8 = [ + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items8, Set([2])), (0, 0)) + + let items9 = [ + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items9, Set([5])), (1, 0)) + + let items10 = [ + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items10, Set([4])), (0, 0)) + + let items11: [ChatItem] = [] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items11, Set([])), (0, 0)) +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift new file mode 100644 index 0000000000..5f2102b8bc --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -0,0 +1,457 @@ +// +// ChatItemsMerger.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 02.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MergedItems: Hashable, Equatable { + let im: ItemsModel + let items: [MergedItem] + let splits: [SplitRange] + // chat item id, index in list + let indexInParentItems: Dictionary + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine("\(items.hashValue)") + } + + static func create(_ im: ItemsModel, _ revealedItems: Set) -> MergedItems { + if im.reversedChatItems.isEmpty { + return MergedItems(im: im, items: [], splits: [], indexInParentItems: [:]) + } + + let unreadCount = im.chatState.unreadTotal + + let unreadAfterItemId = im.chatState.unreadAfterItemId + let itemSplits = im.chatState.splits + var mergedItems: [MergedItem] = [] + // Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems + var splitRanges: [SplitRange] = [] + var indexInParentItems = Dictionary() + var index = 0 + var unclosedSplitIndex: Int? = nil + var unclosedSplitIndexInParent: Int? = nil + var visibleItemIndexInParent = -1 + var unreadBefore = unreadCount - im.chatState.unreadAfterNewestLoaded + var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil + var lastRangeInReversedForMergedItems: BoxedValue>? = nil + var recent: MergedItem? = nil + while index < im.reversedChatItems.count { + let item = im.reversedChatItems[index] + let prev = index >= 1 ? im.reversedChatItems[index - 1] : nil + let next = index + 1 < im.reversedChatItems.count ? im.reversedChatItems[index + 1] : nil + let category = item.mergeCategory + let itemIsSplit = itemSplits.contains(item.id) + + if item.id == unreadAfterItemId { + unreadBefore = unreadCount - im.chatState.unreadAfter + } + if item.isRcvNew { + unreadBefore -= 1 + } + + let revealed = item.mergeCategory == nil || revealedItems.contains(item.id) + if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _, _) = recent, mergeCategory == category, let first = items.boxedValue.first, !revealedItems.contains(first.item.id) && !itemIsSplit { + let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore) + items.boxedValue.append(listItem) + + if item.isRcvNew { + unreadIds.boxedValue.insert(item.id) + } + if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems { + if revealed { + lastRevealedIdsInMergedItems.boxedValue.append(item.id) + } + lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index + } + } else { + visibleItemIndexInParent += 1 + let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore) + if item.mergeCategory != nil { + if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil { + lastRevealedIdsInMergedItems = BoxedValue(revealedItems.contains(item.id) ? [item.id] : []) + } else if revealed, let lastRevealedIdsInMergedItems { + lastRevealedIdsInMergedItems.boxedValue.append(item.id) + } + lastRangeInReversedForMergedItems = BoxedValue(index ... index) + recent = MergedItem.grouped( + items: BoxedValue([listItem]), + revealed: revealed, + revealedIdsWithinGroup: lastRevealedIdsInMergedItems!, + rangeInReversed: lastRangeInReversedForMergedItems!, + mergeCategory: item.mergeCategory, + unreadIds: BoxedValue(item.isRcvNew ? Set(arrayLiteral: item.id) : Set()), + startIndexInReversedItems: index, + hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1)) + ) + } else { + lastRangeInReversedForMergedItems = nil + recent = MergedItem.single( + item: listItem, + startIndexInReversedItems: index, + hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1)) + ) + } + mergedItems.append(recent!) + } + if itemIsSplit { + // found item that is considered as a split + if let unclosedSplitIndex, let unclosedSplitIndexInParent { + // it was at least second split in the list + splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1)) + } + unclosedSplitIndex = index + unclosedSplitIndexInParent = visibleItemIndexInParent + } else if index + 1 == im.reversedChatItems.count, let unclosedSplitIndex, let unclosedSplitIndexInParent { + // just one split for the whole list, there will be no more, it's the end + splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent)) + } + indexInParentItems[item.id] = visibleItemIndexInParent + index += 1 + } + return MergedItems( + im: im, + items: mergedItems, + splits: splitRanges, + indexInParentItems: indexInParentItems + ) + } + + // Use this check to ensure that mergedItems state based on currently actual state of global + // splits and reversedChatItems + func isActualState() -> Bool { + // do not load anything if global splits state is different than in merged items because it + // will produce undefined results in terms of loading and placement of items. + // Same applies to reversedChatItems + return indexInParentItems.count == im.reversedChatItems.count && + splits.count == im.chatState.splits.count && + // that's just an optimization because most of the time only 1 split exists + ((splits.count == 1 && splits[0].itemId == im.chatState.splits[0]) || splits.map({ split in split.itemId }).sorted() == im.chatState.splits.sorted()) + } +} + + +enum MergedItem: Identifiable, Hashable, Equatable { + // equatable and hashable implementations allows to see the difference and correctly scroll to items we want + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.hash == rhs.hash + } + + var id: Int64 { newest().item.id } + + func hash(into hasher: inout Hasher) { + hasher.combine(hash) + } + + var hash: String { + switch self { + case .single(_, _, let hash): hash + " 1" + case .grouped(let items, _, _, _, _, _, _, let hash): hash + " \(items.boxedValue.count)" + } + } + + // the item that is always single, cannot be grouped and always revealed + case single( + item: ListItem, + startIndexInReversedItems: Int, + hash: String + ) + + /** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed, + * there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance + * of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of + * visible items in ChatView's EndlessScrollView */ + case grouped ( + items: BoxedValue<[ListItem]>, + revealed: Bool, + // it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action + // it's the same list instance for all Grouped items within revealed group + /** @see reveal */ + revealedIdsWithinGroup: BoxedValue<[Int64]>, + rangeInReversed: BoxedValue>, + mergeCategory: CIMergeCategory?, + unreadIds: BoxedValue>, + startIndexInReversedItems: Int, + hash: String + ) + + func revealItems(_ reveal: Bool, _ revealedItems: Binding>) { + if case .grouped(let items, _, let revealedIdsWithinGroup, _, _, _, _, _) = self { + var newRevealed = revealedItems.wrappedValue + var i = 0 + if reveal { + while i < items.boxedValue.count { + newRevealed.insert(items.boxedValue[i].item.id) + i += 1 + } + } else { + while i < revealedIdsWithinGroup.boxedValue.count { + newRevealed.remove(revealedIdsWithinGroup.boxedValue[i]) + i += 1 + } + revealedIdsWithinGroup.boxedValue.removeAll() + } + revealedItems.wrappedValue = newRevealed + } + } + + var startIndexInReversedItems: Int { + get { + switch self { + case let .single(_, startIndexInReversedItems, _): startIndexInReversedItems + case let .grouped(_, _, _, _, _, _, startIndexInReversedItems, _): startIndexInReversedItems + } + } + } + + func hasUnread() -> Bool { + switch self { + case let .single(item, _, _): item.item.isRcvNew + case let .grouped(_, _, _, _, _, unreadIds, _, _): !unreadIds.boxedValue.isEmpty + } + } + + func newest() -> ListItem { + switch self { + case let .single(item, _, _): item + case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[0] + } + } + + func oldest() -> ListItem { + switch self { + case let .single(item, _, _): item + case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[items.boxedValue.count - 1] + } + } + + func lastIndexInReversed() -> Int { + switch self { + case .single: startIndexInReversedItems + case let .grouped(items, _, _, _, _, _, _, _): startIndexInReversedItems + items.boxedValue.count - 1 + } + } +} + +struct SplitRange { + let itemId: Int64 + /** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first]) + * so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance + * (3, 4 indexes of the splitRange with the split itself at index 3) + * */ + let indexRangeInReversed: ClosedRange + /** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */ + let indexRangeInParentItems: ClosedRange +} + +struct ListItem: Hashable { + let item: ChatItem + let prevItem: ChatItem? + let nextItem: ChatItem? + // how many unread items before (older than) this one (excluding this one) + let unreadBefore: Int + + private func chatDirHash(_ chatDir: CIDirection?) -> Int { + guard let chatDir else { return 0 } + return switch chatDir { + case .directSnd: 0 + case .directRcv: 1 + case .groupSnd: 2 + case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash + case .localSnd: 4 + case .localRcv: 5 + } + } + + // using meta.hashValue instead of parts takes much more time so better to use partial meta here + func genHash(_ prevRevealed: Bool, _ nextRevealed: Bool) -> String { + "\(item.meta.itemId) \(item.meta.updatedAt.hashValue) \(item.meta.itemEdited) \(item.meta.itemDeleted?.hashValue ?? 0) \(item.meta.itemTimed?.hashValue ?? 0) \(item.meta.itemStatus.hashValue) \(item.meta.sentViaProxy ?? false) \(item.mergeCategory?.hashValue ?? 0) \(chatDirHash(item.chatDir)) \(item.reactions.hashValue) \(item.meta.isRcvNew) \(item.text.hash) \(item.file?.hashValue ?? 0) \(item.quotedItem?.itemId ?? 0) \(unreadBefore) \(prevItem?.id ?? 0) \(chatDirHash(prevItem?.chatDir)) \(prevItem?.mergeCategory?.hashValue ?? 0) \(prevRevealed) \(nextItem?.id ?? 0) \(chatDirHash(nextItem?.chatDir)) \(nextItem?.mergeCategory?.hashValue ?? 0) \(nextRevealed)" + } +} + +class ActiveChatState { + var splits: [Int64] = [] + var unreadAfterItemId: Int64 = -1 + // total items after unread after item (exclusive) + var totalAfter: Int = 0 + var unreadTotal: Int = 0 + // exclusive + var unreadAfter: Int = 0 + // exclusive + var unreadAfterNewestLoaded: Int = 0 + + func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) { + guard let toItemId else { return } + let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId }) + let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId }) + guard let currentIndex, let newIndex else { + return + } + unreadAfterItemId = toItemId + let unreadDiff = newIndex > currentIndex + ? -nonReversedItems[currentIndex + 1.. fromIndex + ? -nonReversedItems[fromIndex + 1..?, _ newItems: [ChatItem]) { + guard let itemIds else { + // special case when the whole chat became read + unreadTotal = 0 + unreadAfter = 0 + return + } + var unreadAfterItemIndex: Int = -1 + // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster + var i = newItems.count - 1 + var ids = itemIds + // intermediate variables to prevent re-setting state value a lot of times without reason + var newUnreadTotal = unreadTotal + var newUnreadAfter = unreadAfter + while i >= 0 { + let item = newItems[i] + if item.id == unreadAfterItemId { + unreadAfterItemIndex = i + } + if ids.contains(item.id) { + // was unread, now this item is read + if (unreadAfterItemIndex == -1) { + newUnreadAfter -= 1 + } + newUnreadTotal -= 1 + ids.remove(item.id) + if ids.isEmpty { + break + } + } + i -= 1 + } + unreadTotal = newUnreadTotal + unreadAfter = newUnreadAfter + } + + func itemAdded(_ item: (Int64, Bool), _ index: Int) { + if item.1 { + unreadAfter += 1 + unreadTotal += 1 + } + } + + func itemsRemoved(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) { + var newSplits: [Int64] = [] + for split in splits { + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split }) + // deleted the item that was right before the split between items, find newer item so it will act like the split + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + // it the whole section is gone and splits overlap, don't add it at all + if let newSplit, !newSplits.contains(newSplit) { + newSplits.append(newSplit) + } + } else { + newSplits.append(split) + } + } + splits = newSplits + + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == unreadAfterItemId }) + // unread after item was removed + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil + if newUnreadAfterItemId == nil { + // everything on top (including unread after item) were deleted, take top item as unread after id + newUnreadAfterItemId = newItems.first?.id + } + if let newUnreadAfterItemId { + unreadAfterItemId = newUnreadAfterItemId + totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count + unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count + unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count + if newUnreadAfterItemWasNull { + // since the unread after item was moved one item after initial position, adjust counters accordingly + if newItems.first?.isRcvNew == true { + unreadTotal += 1 + unreadAfter -= 1 + } + } + } else { + // all items were deleted, 0 items in chatItems + unreadAfterItemId = -1 + totalAfter = 0 + unreadTotal = 0 + unreadAfter = 0 + } + } else { + totalAfter -= itemIds.count + } + } +} + +class BoxedValue: Equatable, Hashable { + static func == (lhs: BoxedValue, rhs: BoxedValue) -> Bool { + lhs.boxedValue == rhs.boxedValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine("\(self)") + } + + var boxedValue : T + init(_ value: T) { + self.boxedValue = value + } +} + +@MainActor +func visibleItemIndexesNonReversed(_ im: ItemsModel, _ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange { + let zero = 0 ... 0 + let items = mergedItems.items + if items.isEmpty { + return zero + } + let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil + let oldest = items.count > listState.lastVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil + guard let newest, let oldest else { + return zero + } + let size = im.reversedChatItems.count + let range = size - oldest ... size - newest + if range.lowerBound < 0 || range.upperBound < 0 { + return zero + } + + // visible items mapped to their underlying data structure which is im.reversedChatItems.reversed() + return range +} diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift new file mode 100644 index 0000000000..2fb1c3fb35 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -0,0 +1,174 @@ +// +// ChatScrollHelpers.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 20.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat, _ im: ItemsModel) async { + await MainActor.run { + loadingMoreItems.wrappedValue = true + loadingBottomItems.wrappedValue = true + } + try? await Task.sleep(nanoseconds: 500_000000) + if ChatModel.shared.chatId != chat.chatInfo.id { + await MainActor.run { + loadingMoreItems.wrappedValue = false + loadingBottomItems.wrappedValue = false + } + return + } + await apiLoadMessages(chat.chatInfo.id, im, ChatPagination.last(count: 50)) + await MainActor.run { + loadingMoreItems.wrappedValue = false + loadingBottomItems.wrappedValue = false + } +} + +func preloadIfNeeded( + _ im: ItemsModel, + _ allowLoadMoreItems: Binding, + _ ignoreLoadingRequests: Binding, + _ listState: EndlessScrollView.ListState, + _ mergedItems: BoxedValue, + loadItems: @escaping (Bool, ChatPagination) async -> Bool, + loadLastItems: @escaping () async -> Void +) { + let state = im.preloadState + guard !listState.isScrolling && !listState.isAnimatedScrolling, + !state.preloading, + listState.totalItemsCount > 0 + else { + return + } + if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count { + state.preloading = true + let allowLoadMore = allowLoadMoreItems.wrappedValue + Task { + defer { state.preloading = false } + var triedToLoad = true + await preloadItems(im, mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in + triedToLoad = await loadItems(false, pagination) + return triedToLoad + } + if triedToLoad { + state.prevFirstVisible = listState.firstVisibleItemId as! Int64 + state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count + } + // it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one + // split will be merged with last items and position of scroll will change unexpectedly. + if listState.itemsCanCoverScreen && !im.lastItemsLoaded { + await loadLastItems() + } + } + } else if listState.itemsCanCoverScreen && !im.lastItemsLoaded { + state.preloading = true + Task { + defer { state.preloading = false } + await loadLastItems() + } + } +} + +func preloadItems( + _ im: ItemsModel, + _ mergedItems: MergedItems, + _ allowLoadMoreItems: Bool, + _ listState: EndlessScrollView.ListState, + _ ignoreLoadingRequests: Binding, + _ loadItems: @escaping (ChatPagination) async -> Bool) +async { + let allowLoad = allowLoadMoreItems || mergedItems.items.count == listState.lastVisibleItemIndex + 1 + let remaining = ChatPagination.UNTIL_PRELOAD_COUNT + let firstVisibleIndex = listState.firstVisibleItemIndex + + if !(await preloadItemsBefore()) { + await preloadItemsAfter() + } + + func preloadItemsBefore() async -> Bool { + let splits = mergedItems.splits + let lastVisibleIndex = listState.lastVisibleItemIndex + var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) + let items: [ChatItem] = im.reversedChatItems.reversed() + if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining { + lastIndexToLoadFrom = items.count - 1 + } + let loadFromItemId: Int64? + if allowLoad, let lastIndexToLoadFrom { + let index = items.count - 1 - lastIndexToLoadFrom + loadFromItemId = index >= 0 ? items[index].id : nil + } else { + loadFromItemId = nil + } + guard let loadFromItemId, ignoreLoadingRequests.wrappedValue != loadFromItemId else { + return false + } + let sizeWas = items.count + let firstItemIdWas = items.first?.id + let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT)) + if triedToLoad && sizeWas == im.reversedChatItems.count && firstItemIdWas == im.reversedChatItems.last?.id { + ignoreLoadingRequests.wrappedValue = loadFromItemId + return false + } + return triedToLoad + } + + func preloadItemsAfter() async { + let splits = mergedItems.splits + let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) }) + // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) + let reversedItems: [ChatItem] = im.reversedChatItems + if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex { + let index = split.indexRangeInReversed.lowerBound + if index >= 0 { + let loadFromItemId = reversedItems[index].id + _ = await loadItems(ChatPagination.after(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT)) + } + } + } +} + +func oldestPartiallyVisibleListItemInListStateOrNull(_ listState: EndlessScrollView.ListState) -> ListItem? { + if listState.lastVisibleItemIndex < listState.items.count { + return listState.items[listState.lastVisibleItemIndex].oldest() + } else { + return listState.items.last?.oldest() + } +} + +private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? { + for split in splits { + // before any split + if split.indexRangeInParentItems.lowerBound > firstVisibleIndex { + if lastVisibleIndex > (split.indexRangeInParentItems.lowerBound - remaining) { + return split.indexRangeInReversed.lowerBound - 1 + } + break + } + let containsInRange = split.indexRangeInParentItems.contains(firstVisibleIndex) + if containsInRange { + if lastVisibleIndex > (split.indexRangeInParentItems.upperBound - remaining) { + return split.indexRangeInReversed.upperBound + } + break + } + } + return nil +} + +/// Disable animation on iOS 15 +func withConditionalAnimation( + _ animation: Animation? = .default, + _ body: () throws -> Result +) rethrows -> Result { + if #available(iOS 16.0, *) { + try withAnimation(animation, body) + } else { + try body() + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index baceb5b4ab..8ce0c50849 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -15,40 +15,56 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel - @ObservedObject var im = ItemsModel.shared + @State var revealedItems: Set = Set() @State var theme: AppTheme = buildTheme() @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @Environment(\.presentationMode) var presentationMode @Environment(\.scenePhase) var scenePhase @State @ObservedObject var chat: Chat - @StateObject private var scrollModel = ReverseListScrollModel() + @ObservedObject var im: ItemsModel + @State var mergedItems: BoxedValue + @State var floatingButtonModel: FloatingButtonModel + @Binding var scrollToItemId: ChatItem.ID? @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() + @State private var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? - @State private var loadingItems = false - @State private var firstPage = false - @State private var revealedChatItem: ChatItem? - @State private var searchMode = false + @State private var loadingMoreItems = false + @State private var loadingTopItems = false + @State private var requestedTopScroll = false + @State private var loadingBottomItems = false + @State private var requestedBottomScroll = false + @State private var showSearch = false @State private var searchText: String = "" @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @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 @State private var showDeleteSelectedMessages: Bool = false + @State private var showArchiveSelectedReports: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false + @State private var allowLoadMoreItems: Bool = false + @State private var ignoreLoadingRequests: Int64? = nil + @State private var animatedScrollingInProgress: Bool = false + @State private var showUserSupportChatSheet = false + + @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + var body: some View { if #available(iOS 16.0, *) { viewBody @@ -59,44 +75,80 @@ struct ChatView: View { } } - @ViewBuilder private var viewBody: some View { let cInfo = chat.chatInfo - ZStack { + let memberSupportChat: (groupInfo: GroupInfo, member: GroupMember?)? = + if case let .group(groupInfo, .memberSupport(member)) = cInfo { + (groupInfo, member) + } else { + nil + } + let userMemberKnockingChat = memberSupportChat?.groupInfo.membership.memberPending == true + return ZStack { let wallpaperImage = theme.wallpaper.type.image let wallpaperType = theme.wallpaper.type let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background) let tintColor = theme.wallpaper.tint ?? wallpaperType.defaultTintColor(theme.base) Color.clear.ignoresSafeArea(.all) - .if(wallpaperImage != nil) { view in + .if(wallpaperImage != nil && im.secondaryIMFilter == nil) { view in view.modifier( ChatViewBackground(image: wallpaperImage!, imageType: wallpaperType, background: backgroundColor, tint: tintColor) ) } VStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { - chatItemsList() - FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) + if userMemberKnockingChat { + ZStack(alignment: .top) { + chatItemsList() + userMemberKnockingTitleBar() + } + } else { + chatItemsList() + } + if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { + GroupMentionsView(im: im, groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) + } + FloatingButtons(im: im, theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: { + mergedItems.boxedValue = MergedItems.create(im, revealedItems) + scrollView.updateItems(mergedItems.boxedValue.items) + } + ) } connectingText() if selectedChatItems == nil { + let reason = chat.chatInfo.userCantSendReason ComposeView( chat: chat, + im: im, composeState: $composeState, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, + selectedRange: $selectedRange, + disabledText: reason?.composeLabel ) .disabled(!cInfo.sendMsgEnabled) + .if(!cInfo.sendMsgEnabled) { v in + v.disabled(true).onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: reason?.alertMessage + ) + } + } } else { SelectedItemsBottomToolbar( - chatItems: ItemsModel.shared.reversedChatItems, + im: im, selectedChatItems: $selectedChatItems, chatInfo: chat.chatInfo, deleteItems: { forAll in allowToDeleteSelectedMessagesForAll = forAll showDeleteSelectedMessages = true }, + archiveItems: { + showArchiveSelectedReports = true + }, moderateItems: { - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { showModerateSelectedMessagesAlert(groupInfo) } }, @@ -104,15 +156,22 @@ struct ChatView: View { ) } } + if im.showLoadingProgress == chat.id { + ProgressView().scaleEffect(2) + } } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { - if searchMode { searchToolbar() } + if showSearch { searchToolbar() } Divider() } .background(ToolbarMaterial.material(toolbarMaterial)) } - .navigationTitle(cInfo.chatViewName) + .navigationTitle( + memberSupportChat == nil + ? cInfo.chatViewName + : memberSupportChat?.member?.chatViewName ?? NSLocalizedString("Chat with admins", comment: "chat toolbar") + ) .background(theme.colors.background) .navigationBarTitleDisplayMode(.inline) .environmentObject(theme) @@ -130,24 +189,35 @@ struct ChatView: View { } } } - .appSheet(item: $selectedMember) { member in - Group { - if case let .group(groupInfo) = chat.chatInfo { - GroupMemberInfoView( - groupInfo: groupInfo, - chat: chat, - groupMember: member, - navigation: true - ) + .confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) { + Button("For me", role: .destructive) { + if let selected = selectedChatItems { + archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages) } } + if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { + Button("For all moderators", role: .destructive) { + if let selected = selectedChatItems { + archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages) + } + } + } + } + .appSheet(item: $selectedMember) { member in + if case let .group(groupInfo, _) = chat.chatInfo { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: member, + scrollToItemId: $scrollToItemId, + navigation: true + ) + } } // it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field .appSheet(isPresented: $showAddMembersSheet) { - Group { - if case let .group(groupInfo) = cInfo { - AddGroupMembersView(chat: chat, groupInfo: groupInfo) - } + if case let .group(groupInfo, _) = cInfo { + AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } .sheet(isPresented: Binding( @@ -166,44 +236,108 @@ struct ChatView: View { ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState) } } + .appSheet( + isPresented: $showUserSupportChatSheet, + onDismiss: { + if chat.chatInfo.groupInfo?.membership.memberPending ?? false { + chatModel.chatId = nil + } + } + ) { + if let groupInfo = cInfo.groupInfo { + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) + } + } .onAppear { + scrollView.listState.onUpdateListener = onChatItemsUpdated selectedChatItems = nil + revealedItems = Set() initChatView() + if im.isLoading { + Task { + try? await Task.sleep(nanoseconds: 500_000000) + await MainActor.run { + if im.isLoading { + im.showLoadingProgress = chat.id + } + } + } + } + // if this is the main chat of the group with the pending member (knocking) + if case let .group(groupInfo, nil) = chat.chatInfo, + groupInfo.membership.memberPending { + ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) { + showUserSupportChatSheet = true + chatModel.secondaryPendingInviteeChatOpened = true + } + } } .onChange(of: chatModel.chatId) { cId in showChatInfoSheet = false selectedChatItems = nil - scrollModel.scrollToBottom() + revealedItems = Set() stopAudioPlayer() if let cId { if let c = chatModel.getChat(cId) { chat = c } + scrollView.listState.onUpdateListener = onChatItemsUpdated initChatView() theme = buildTheme() + closeSearch() + mergedItems.boxedValue = MergedItems.create(im, revealedItems) + scrollView.updateItems(mergedItems.boxedValue.items) + + if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { + scrollView.scrollToItem(unreadIndex) + } else { + scrollView.scrollToBottom() + } + if chatModel.openAroundItemId != nil { + chatModel.openAroundItemId = nil + } } else { dismiss() } } - .onChange(of: revealedChatItem) { _ in - NotificationCenter.postReverseListNeedsLayout() - } - .onChange(of: im.isLoading) { isLoading in - if !isLoading, - im.reversedChatItems.count <= loadItemsPerPage, - filtered(im.reversedChatItems).count < 10 { - loadChatItems(chat.chatInfo) + .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { opened in + if im.secondaryIMFilter != nil && !opened { + dismiss() + } + } + .onChange(of: chatModel.openAroundItemId) { openAround in + if let openAround { + closeSearch() + mergedItems.boxedValue = MergedItems.create(im, revealedItems) + scrollView.updateItems(mergedItems.boxedValue.items) + chatModel.openAroundItemId = nil + + if let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } + + // this may already being loading because of changed chat id (see .onChange(of: chat.id) + if !loadingBottomItems { + allowLoadMoreItems = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } } } - .environmentObject(scrollModel) .onDisappear { VideoPlayerView.players.removeAll() stopAudioPlayer() if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { - chatModel.chatItemStatuses = [:] - ItemsModel.shared.reversedChatItems = [] + im.reversedChatItems = [] + im.chatState.clear() chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false @@ -216,133 +350,220 @@ struct ChatView: View { } .toolbar { ToolbarItem(placement: .principal) { - if selectedChatItems != nil { - SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) - } else if case let .direct(contact) = cInfo { - Button { - Task { - showChatInfoSheet = true - } - } label: { - ChatInfoToolbar(chat: chat) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - ChatInfoView( - chat: chat, - contact: contact, - localAlias: chat.chatInfo.localAlias, - featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - onSearch: { focusSearch() } - ) - } - } else if case let .group(groupInfo) = cInfo { - Button { - Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } - } label: { - ChatInfoToolbar(chat: chat) - .tint(theme.colors.primary) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - GroupChatInfoView( - chat: chat, - groupInfo: Binding( - get: { groupInfo }, - set: { gInfo in - chat.chatInfo = .group(groupInfo: gInfo) - chat.created = Date.now - } - ), - onSearch: { focusSearch() }, - localAlias: groupInfo.localAlias - ) - } - } else if case .local = cInfo { - ChatInfoToolbar(chat: chat) + if im.secondaryIMFilter == nil { + primaryPrincipalToolbarContent() + } else if !userMemberKnockingChat { // no toolbar while knocking chat, it's unstable on sheet + secondaryPrincipalToolbarContent() } } ToolbarItem(placement: .navigationBarTrailing) { - let isLoading = im.isLoading && im.showLoadingProgress - if selectedChatItems != nil { - Button { - withAnimation { - selectedChatItems = nil - } - } label: { - Text("Cancel") - } - } else { - switch cInfo { - case let .direct(contact): - HStack { - let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } - } - Menu { - if !isLoading { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") - } - .disabled(!contact.ready || !contact.active) - } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) - } - } label: { - Image(systemName: "ellipsis") - .tint(isLoading ? Color.clear : nil) - .overlay { if isLoading { ProgressView() } } - } - } - case let .group(groupInfo): - HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } - } else { - addMembersButton() - } - } - Menu { - if !isLoading { - searchButton() - ToggleNtfsButton(chat: chat) - } - } label: { - Image(systemName: "ellipsis") - .tint(isLoading ? Color.clear : nil) - .overlay { if isLoading { ProgressView() } } - } - } - case .local: - searchButton() - default: - EmptyView() + if im.secondaryIMFilter == nil { + primaryTrailingToolbarContent() + } else if !userMemberKnockingChat { + secondaryTrailingToolbarContent() + } + } + } + .if(im.secondaryIMFilter == nil) { v in + v.onChange(of: scrollToItemId) { itemId in + if let itemId = itemId { + dismissAllSheets(animated: false) { + scrollToItem(itemId) + scrollToItemId = nil } } } } } - + + @inline(__always) + @ViewBuilder private func primaryPrincipalToolbarContent() -> some View { + let cInfo = chat.chatInfo + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else if case let .direct(contact) = cInfo { + Button { + Task { + showChatInfoSheet = true + } + } label: { + ChatInfoToolbar(chat: chat) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + ChatInfoView( + chat: chat, + contact: contact, + localAlias: chat.chatInfo.localAlias, + featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + onSearch: { focusSearch() } + ) + } + } else if case let .group(groupInfo, _) = cInfo { + Button { + Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } + } label: { + ChatInfoToolbar(chat: chat) + .tint(theme.colors.primary) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + GroupChatInfoView( + chat: chat, + groupInfo: Binding( + get: { groupInfo }, + set: { gInfo in + chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil) + chat.created = Date.now + } + ), + scrollToItemId: $scrollToItemId, + onSearch: { focusSearch() }, + localAlias: groupInfo.localAlias + ) + } + } else if case .local = cInfo { + ChatInfoToolbar(chat: chat) + } + } + + @inline(__always) + @ViewBuilder private func primaryTrailingToolbarContent() -> some View { + let cInfo = chat.chatInfo + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } + } else { + switch cInfo { + case let .direct(contact): + HStack { + let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + if callsPrefEnabled { + if chatModel.activeCall == nil { + callButton(contact, .audio, imageName: "phone") + .disabled(!contact.ready || !contact.active) + } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } + } + Menu { + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") + } + .disabled(!contact.ready || !contact.active) + } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) + } label: { + Image(systemName: "ellipsis") + } + } + case let .group(groupInfo, _): + HStack { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + } + } + Menu { + searchButton() + ToggleNtfsButton(chat: chat) + } label: { + Image(systemName: "ellipsis") + } + } + case .local: + searchButton() + default: + EmptyView() + } + } + } + + @inline(__always) + @ViewBuilder private func secondaryPrincipalToolbarContent() -> some View { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else { + switch im.secondaryIMFilter { + case let .groupChatScopeContext(groupScopeInfo): + switch groupScopeInfo { + case let .memberSupport(groupMember_): + if let groupMember = groupMember_ { + MemberSupportChatToolbar(groupMember: groupMember) + } else { + textChatToolbar("Chat with admins") + } + } + case let .msgContentTagContext(contentTag): + switch contentTag { + case .report: + textChatToolbar("Member reports") + default: + EmptyView() + } + case .none: + EmptyView() + } + } + } + + @inline(__always) + @ViewBuilder private func secondaryTrailingToolbarContent() -> some View { + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } + } else { + searchButton() + } + } + + @inline(__always) + private func userMemberKnockingTitleBar() -> some View { + VStack(spacing: 0) { + Text("Chat with admins") + .font(.headline) + .foregroundColor(theme.colors.onBackground) + .padding(.top, 8) + .padding(.bottom, 14) + .frame(maxWidth: .infinity) + .background(ToolbarMaterial.material(toolbarMaterial)) + Divider() + } + } + + func textChatToolbar(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.headline) + .lineLimit(1) + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) + } + private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. @@ -370,7 +591,40 @@ struct ChatView: View { await markChatUnread(chat, unreadChat: false) } } - ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount + floatingButtonModel.updateOnListChange(scrollView.listState) + } + + private func scrollToItem(_ itemId: ChatItem.ID) { + Task { + do { + var index = mergedItems.boxedValue.indexInParentItems[itemId] + if index == nil { + let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2) + let oldSize = im.reversedChatItems.count + let triedToLoad = await loadChatItems(chat, pagination) + if !triedToLoad { + return + } + var repeatsLeft = 50 + while oldSize == im.reversedChatItems.count && repeatsLeft > 0 { + try await Task.sleep(nanoseconds: 20_000000) + repeatsLeft -= 1 + } + index = mergedItems.boxedValue.indexInParentItems[itemId] + } + if let index { + closeKeyboardAndRun { + Task { + await MainActor.run { animatedScrollingInProgress = true } + await scrollView.scrollToItemAnimated(min(im.reversedChatItems.count - 1, index)) + await MainActor.run { animatedScrollingInProgress = false } + } + } + } + } catch { + logger.error("Error scrolling to item: \(error)") + } + } } private func searchToolbar() -> some View { @@ -394,16 +648,14 @@ struct ChatView: View { .cornerRadius(10.0) Button ("Cancel") { - searchText = "" - searchMode = false - searchFocussed = false - Task { await loadChat(chat: chat) } + closeSearch() + searchTextChanged("") } } .padding(.horizontal) .padding(.vertical, 8) } - + private func voiceWithoutFrame(_ ci: ChatItem) -> Bool { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } @@ -421,49 +673,86 @@ struct ChatView: View { .map { $0.element } } - private func chatItemsList() -> some View { let cInfo = chat.chatInfo - let mergedItems = filtered(im.reversedChatItems) return GeometryReader { g in - ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in + //let _ = logger.debug("Reloading chatItemsList with number of itmes: \(im.reversedChatItems.count)") + ScrollRepresentable(scrollView: scrollView) { (index: Int, mergedItem: MergedItem) in + let ci = switch mergedItem { + case let .single(item, _, _): item.item + case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue.last!.item + } let voiceNoFrame = voiceWithoutFrame(ci) let maxWidth = cInfo.chatType == .group - ? voiceNoFrame - ? (g.size.width - 28) - 42 - : (g.size.width - 28) * 0.84 - 42 - : voiceNoFrame - ? (g.size.width - 32) - : (g.size.width - 32) * 0.84 + ? voiceNoFrame + ? (g.size.width - 28) - 42 + : (g.size.width - 28) * 0.84 - 42 + : voiceNoFrame + ? (g.size.width - 32) + : (g.size.width - 32) * 0.84 return ChatItemWithMenu( + im: im, chat: $chat, + index: index, + isLastItem: index == mergedItems.boxedValue.items.count - 1, chatItem: ci, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, + merged: mergedItem, maxWidth: maxWidth, composeState: $composeState, selectedMember: $selectedMember, showChatInfoSheet: $showChatInfoSheet, - revealedChatItem: $revealedChatItem, + revealedItems: $revealedItems, selectedChatItems: $selectedChatItems, - forwardedChatItems: $forwardedChatItems + forwardedChatItems: $forwardedChatItems, + searchText: $searchText, + closeKeyboardAndRun: closeKeyboardAndRun ) + // crashes on Cell size calculation without this line + .environmentObject(ChatModel.shared) + .environmentObject(theme) // crashes without this line when scrolling to the first unread in EndlessScrollVIew .id(ci.id) // Required to trigger `onAppear` on iOS15 - } loadPage: { - loadChatItems(cInfo) } - .opacity(ItemsModel.shared.isLoading ? 0 : 1) - .padding(.vertical, -InvertedTableView.inset) - .onTapGesture { hideKeyboard() } - .onChange(of: searchText) { _ in - Task { await loadChat(chat: chat, search: searchText) } + .onAppear { + if !im.isLoading { + updateWithInitiallyLoadedItems() + } } - .onChange(of: im.itemAdded) { added in - if added { + .onChange(of: im.isLoading) { loading in + if !loading { + updateWithInitiallyLoadedItems() + } + } + .onChange(of: im.reversedChatItems) { items in + mergedItems.boxedValue = MergedItems.create(im, revealedItems) + scrollView.updateItems(mergedItems.boxedValue.items) + if im.itemAdded { im.itemAdded = false - if FloatingButtonModel.shared.isReallyNearBottom { - scrollModel.scrollToBottom() + if scrollView.listState.firstVisibleItemIndex < 2 { + scrollView.scrollToBottomAnimated() + } else { + scrollView.scroll(by: 34) } } } + .onChange(of: revealedItems) { revealed in + mergedItems.boxedValue = MergedItems.create(im, revealed) + scrollView.updateItems(mergedItems.boxedValue.items) + } + .onChange(of: chat.id) { _ in + allowLoadMoreItems = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } + .padding(.vertical, -100) + .onTapGesture { hideKeyboard() } + .onChange(of: searchText) { s in + if showSearch { + searchTextChanged(s) + } + } } } @@ -481,89 +770,58 @@ struct ChatView: View { } } - class FloatingButtonModel: ObservableObject { - static let shared = FloatingButtonModel() - @Published var unreadBelow: Int = 0 - @Published var isNearBottom: Bool = true - @Published var date: Date? - @Published var isDateVisible: Bool = false - var totalUnread: Int = 0 - var isReallyNearBottom: Bool = true - var hideDateWorkItem: DispatchWorkItem? + private func updateWithInitiallyLoadedItems() { + if mergedItems.boxedValue.items.isEmpty { + mergedItems.boxedValue = MergedItems.create(im, revealedItems) + } + let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) + let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } + // this helps to speed up initial process of setting scroll position and reduce time needed + // to layout items on screen + if let unreadIndex, let unreadItemId { + scrollView.setScrollPosition(unreadIndex, unreadItemId) + } + scrollView.updateItems(mergedItems.boxedValue.items) + if let unreadIndex { + scrollView.scrollToItem(unreadIndex) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } - func updateOnListChange(_ listState: ListState) { - let im = ItemsModel.shared - let unreadBelow = - if let id = listState.bottomItemId, - let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) - { - im.reversedChatItems[.. 0 && listState.scrollOffset < 500 + private func searchTextChanged(_ s: String) { + Task { + await loadChat(chat: chat, im: im, search: s) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) + await MainActor.run { + scrollView.updateItems(mergedItems.boxedValue.items) } - - // set floating button indication mode - let nearBottom = listState.scrollOffset < 800 - if nearBottom != self.isNearBottom { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in - self?.isNearBottom = nearBottom - } - } - - // hide Date indicator after 1 second of no scrolling - hideDateWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - guard let it = self else { return } - it.setDate(visibility: false) - it.hideDateWorkItem = nil - } - DispatchQueue.main.async { [weak self] in - self?.hideDateWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) + if !s.isEmpty { + scrollView.scrollToBottom() + } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { + // scroll to the top unread item + scrollView.scrollToItem(index) + } else { + scrollView.scrollToBottom() } } - - func resetDate() { - date = nil - isDateVisible = false - } - - private func setDate(visibility isVisible: Bool) { - if isVisible { - if !isNearBottom, - !isDateVisible, - let date, !Calendar.current.isDateInToday(date) { - withAnimation { self.isDateVisible = true } - } - } else if isDateVisible { - withAnimation { self.isDateVisible = false } - } - } - } private struct FloatingButtons: View { + @ObservedObject var im: ItemsModel let theme: AppTheme - let scrollModel: ReverseListScrollModel + let scrollView: EndlessScrollView let chat: Chat - @ObservedObject var model = FloatingButtonModel.shared + @Binding var loadingMoreItems: Bool + @Binding var loadingTopItems: Bool + @Binding var requestedTopScroll: Bool + @Binding var loadingBottomItems: Bool + @Binding var requestedBottomScroll: Bool + @Binding var animatedScrollingInProgress: Bool + let listState: EndlessScrollView.ListState + @ObservedObject var model: FloatingButtonModel + let reloadItems: () -> Void var body: some View { ZStack(alignment: .top) { @@ -573,52 +831,113 @@ struct ChatView: View { .background(.thinMaterial) .clipShape(Capsule()) .opacity(model.isDateVisible ? 1 : 0) + .padding(.vertical, 4) } VStack { - let unreadAbove = model.totalUnread - model.unreadBelow - if unreadAbove > 0 { - circleButton { - unreadCountText(unreadAbove) - .font(.callout) - .foregroundColor(theme.colors.primary) - } - .onTapGesture { - scrollModel.scrollToNextPage() - } - .contextMenu { - Button { - Task { - await markChatRead(chat) + if model.unreadAbove > 0 && !animatedScrollingInProgress { + if loadingTopItems && requestedTopScroll { + circleButton { ProgressView() } + } else { + circleButton { + unreadCountText(model.unreadAbove) + .font(.callout) + .foregroundColor(theme.colors.primary) + } + .onTapGesture { + if loadingTopItems { + requestedTopScroll = true + requestedBottomScroll = false + } else { + scrollToTopUnread() + } + } + .contextMenu { + Button { + Task { + await markChatRead(im, chat) + } + } label: { + Label("Mark read", systemImage: "checkmark") } - } label: { - Label("Mark read", systemImage: "checkmark") } } } Spacer() - if model.unreadBelow > 0 { - circleButton { - unreadCountText(model.unreadBelow) - .font(.callout) - .foregroundColor(theme.colors.primary) + if listState.firstVisibleItemIndex != 0 && !animatedScrollingInProgress { + if loadingBottomItems && requestedBottomScroll { + circleButton { ProgressView() } + } else { + circleButton { + Group { + if model.unreadBelow > 0 { + unreadCountText(model.unreadBelow) + .font(.callout) + .foregroundColor(theme.colors.primary) + } else { + Image(systemName: "chevron.down").foregroundColor(theme.colors.primary) + } + } + } + .onTapGesture { + if loadingBottomItems || !im.lastItemsLoaded { + requestedTopScroll = false + requestedBottomScroll = true + } else { + scrollToBottom() + } + } } - .onTapGesture { - scrollModel.scrollToBottom() - } - } else if !model.isNearBottom { - circleButton { - Image(systemName: "chevron.down") - .foregroundColor(theme.colors.primary) - } - .onTapGesture { scrollModel.scrollToBottom() } } } .padding() .frame(maxWidth: .infinity, alignment: .trailing) } + .onChange(of: loadingTopItems) { loading in + if !loading && requestedTopScroll { + requestedTopScroll = false + scrollToTopUnread() + } + } + .onChange(of: loadingBottomItems) { loading in + if !loading && requestedBottomScroll && im.lastItemsLoaded { + requestedBottomScroll = false + scrollToBottom() + } + } .onDisappear(perform: model.resetDate) } + private func scrollToTopUnread() { + Task { + if !im.chatState.splits.isEmpty { + await MainActor.run { loadingMoreItems = true } + await loadChat(chatId: chat.id, im: im, openAroundItemId: nil, clearItems: false) + await MainActor.run { reloadItems() } + if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { + await MainActor.run { animatedScrollingInProgress = true } + await scrollView.scrollToItemAnimated(index) + await MainActor.run { animatedScrollingInProgress = false } + } + await MainActor.run { loadingMoreItems = false } + } else if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { + await MainActor.run { animatedScrollingInProgress = true } + // scroll to the top unread item + await scrollView.scrollToItemAnimated(index) + await MainActor.run { animatedScrollingInProgress = false } + } else { + logger.debug("No more unread items, total: \(listState.items.count)") + } + } + } + + private func scrollToBottom() { + animatedScrollingInProgress = true + Task { + await scrollView.scrollToItemAnimated(0, top: false) + await MainActor.run { animatedScrollingInProgress = false } + } + } + private func circleButton(_ content: @escaping () -> Content) -> some View { ZStack { Circle() @@ -677,14 +996,32 @@ struct ChatView: View { } private func focusSearch() { - searchMode = true + showSearch = true searchFocussed = true searchText = "" } + private func closeSearch() { + showSearch = false + searchText = "" + searchFocussed = false + } + + private func closeKeyboardAndRun(_ action: @escaping () -> Void) { + var delay: TimeInterval = 0 + if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch { + delay = 0.5 + closeSearch() + hideKeyboard() + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + action() + } + } + private func addMembersButton() -> some View { Button { - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { @@ -694,7 +1031,7 @@ struct ChatView: View { private func groupLinkButton() -> some View { Button { - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { Task { do { if let link = try apiGetGroupLink(gInfo.groupId) { @@ -745,6 +1082,7 @@ struct ChatView: View { let (validItems, confirmation) = try await apiPlanForwardChatItems( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemIds: Array(selectedChatItems) ) if let confirmation { @@ -810,7 +1148,7 @@ struct ChatView: View { ) } } - + func forwardAction(_ items: [Int64]) -> UIAlertAction { UIAlertAction( title: NSLocalizedString("Forward messages", comment: "alert action"), @@ -834,7 +1172,6 @@ struct ChatView: View { } func openForwardingSheet(_ items: [Int64]) async { - let im = ItemsModel.shared var items = Set(items) var fci = [ChatItem]() for reversedChatItem in im.reversedChatItems { @@ -848,43 +1185,38 @@ struct ChatView: View { } } - private func loadChatItems(_ cInfo: ChatInfo) { - Task { - if loadingItems || firstPage { return } - loadingItems = true - do { - var reversedPage = Array() - var chatItemsAvailable = true - // Load additional items until the page is +50 large after merging - while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { - let pagination: ChatPagination = - if let lastItem = reversedPage.last ?? im.reversedChatItems.last { - .before(chatItemId: lastItem.id, count: loadItemsPerPage) - } else { - .last(count: loadItemsPerPage) - } - let chatItems = try await apiGetChatItems( - type: cInfo.chatType, - id: cInfo.apiId, - pagination: pagination, - search: searchText - ) - chatItemsAvailable = !chatItems.isEmpty - reversedPage.append(contentsOf: chatItems.reversed()) - } - await MainActor.run { - if reversedPage.count == 0 { - firstPage = true - } else { - im.reversedChatItems.append(contentsOf: reversedPage) - } - loadingItems = false - } - } catch let error { - logger.error("apiGetChat error: \(responseError(error))") - await MainActor.run { loadingItems = false } + private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { + if loadingMoreItems { return false } + await MainActor.run { + loadingMoreItems = true + if case .before = pagination { + loadingTopItems = true + } else if case .after = pagination { + loadingBottomItems = true } } + let triedToLoad = await loadChatItemsUnchecked(chat, pagination) + await MainActor.run { + loadingMoreItems = false + if case .before = pagination { + loadingTopItems = false + } else if case .after = pagination { + loadingBottomItems = false + } + } + return triedToLoad + } + + private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { + await apiLoadMessages( + chat.chatInfo.id, + im, + pagination, + searchText, + nil, + { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) } + ) + return true } func stopAudioPlayer() { @@ -892,96 +1224,151 @@ struct ChatView: View { VoiceItemState.chatView = [:] } + func onChatItemsUpdated() { + if !mergedItems.boxedValue.isActualState() { + //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)") + return + } + floatingButtonModel.updateOnListChange(scrollView.listState) + preloadIfNeeded( + im, + $allowLoadMoreItems, + $ignoreLoadingRequests, + scrollView.listState, + mergedItems, + loadItems: { unchecked, pagination in + if unchecked { + await loadChatItemsUnchecked(chat, pagination) + } else { + await loadChatItems(chat, pagination) + } + }, + loadLastItems: { + if !loadingMoreItems { + await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat, im) + } + } + ) + } + private struct ChatItemWithMenu: View { + @ObservedObject var im: ItemsModel @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner @Binding @ObservedObject var chat: Chat @ObservedObject var dummyModel: ChatItemDummyModel = .shared + let index: Int + let isLastItem: Bool let chatItem: ChatItem + let scrollToItem: (ChatItem.ID) -> Void + @Binding var scrollToItemId: ChatItem.ID? + let merged: MergedItem let maxWidth: CGFloat @Binding var composeState: ComposeState @Binding var selectedMember: GMember? @Binding var showChatInfoSheet: Bool - @Binding var revealedChatItem: ChatItem? + @Binding var revealedItems: Set @State private var deletingItem: ChatItem? = nil @State private var showDeleteMessage = false @State private var deletingItems: [Int64] = [] @State private var showDeleteMessages = false + @State private var archivingReports: Set? = nil + @State private var showArchivingReports = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var msgWidth: CGFloat = 0 + @State private var touchInProgress: Bool = false @Binding var selectedChatItems: Set? @Binding var forwardedChatItems: [ChatItem] + @Binding var searchText: String + var closeKeyboardAndRun: (@escaping () -> Void) -> Void + @State private var allowMenu: Bool = true @State private var markedRead = false + @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil - var revealed: Bool { chatItem == revealedChatItem } + var revealed: Bool { revealedItems.contains(chatItem.id) } typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?) - func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation { - let im = ItemsModel.shared - if let i, i > 0 && im.reversedChatItems.count >= i { - let nextItem = im.reversedChatItems[i - 1] - let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60 - return ( - timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs), - largeGap: largeGap, - date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs - ) + private func reveal(_ yes: Bool) -> Void { + merged.revealItems(yes, $revealedItems) + } + + func getItemSeparation(_ chatItem: ChatItem, _ prevItem: ChatItem?) -> ItemSeparation { + guard let prevItem else { + return ItemSeparation(timestamp: true, largeGap: true, date: nil) + } + + let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir { + groupMember.groupMemberId == prevGroupMember.groupMemberId } else { - return (timestamp: true, largeGap: true, date: nil) + chatItem.chatDir.sent == prevItem.chatDir.sent + } + let largeGap = !sameMemberAndDirection || prevItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60 + + return ItemSeparation( + timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(prevItem.meta.itemTs), + largeGap: largeGap, + date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: prevItem.meta.itemTs) ? nil : prevItem.meta.itemTs + ) + } + + func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool { + let oldIsGroupRcv = switch older?.chatDir { + case .groupRcv: true + default: false + } + let sameMember = switch (older?.chatDir, current.chatDir) { + case (.groupRcv(let oldMember), .groupRcv(let member)): + oldMember.memberId == member.memberId + default: + false + } + if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { + return true + } else { + return false } } var body: some View { - let currIndex = m.getChatItemIndex(chatItem) - let ciCategory = chatItem.mergeCategory - let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) - let range = itemsRange(currIndex, prevHidden) - let timeSeparation = getItemSeparation(chatItem, at: currIndex) - let im = ItemsModel.shared - Group { - if revealed, let range = range { - let items = Array(zip(Array(range), im.reversedChatItems[range])) - VStack(spacing: 0) { - ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in - let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] - chatItemView(ci, nil, prev, getItemSeparation(ci, at: i)) - .overlay { - if let selected = selectedChatItems, ci.canBeDeletedForSelf { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - let checked = selected.contains(ci.id) - selectUnselectChatItem(select: !checked, ci) - } - } - } - } - } - } else { - VStack(spacing: 0) { - chatItemView(chatItem, range, prevItem, timeSeparation) - if let date = timeSeparation.date { - DateSeparator(date: date).padding(8) - } - } + let last = isLastItem ? im.reversedChatItems.last : nil + let listItem = merged.newest() + let item = listItem.item + let range: ClosedRange? = if case let .grouped(_, _, _, rangeInReversed, _, _, _, _) = merged { + rangeInReversed.boxedValue + } else { + nil + } + let showAvatar = shouldShowAvatar(item, listItem.nextItem) + let single = switch merged { + case .single: true + default: false + } + let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil) + return VStack(spacing: 0) { + if let last { + DateSeparator(date: last.meta.itemTs).padding(8) + } + chatItemListView(range, showAvatar, item, itemSeparation) .overlay { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { Color.clear .contentShape(Rectangle()) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { let checked = selected.contains(chatItem.id) selectUnselectChatItem(select: !checked, chatItem) - } + }) } } + if let date = itemSeparation.date { + DateSeparator(date: date).padding(8) } } .onAppear { @@ -991,42 +1378,73 @@ struct ChatView: View { markedRead = true } if let range { - let itemIds = unreadItemIds(range) + let (itemIds, unreadMentions) = unreadItemIds(range) if !itemIds.isEmpty { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, itemIds) + await apiMarkChatItemsRead(im, chat.chatInfo, itemIds, mentionsRead: unreadMentions) } } } else if chatItem.isRcvNew { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id]) + await apiMarkChatItemsRead(im, chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) } } } + .onDisappear { + markReadTask?.cancel() + markedRead = false + } .actionSheet(item: $actionSheet) { $0.actionSheet } + // skip updating struct on touch if no need to show GoTo button + .if(touchInProgress || searchIsNotBlank || (chatItem.meta.itemForwarded != nil && chatItem.meta.itemForwarded != .unknown)) { + // long press listener steals taps from top-level listener, so repeating it's logic here as well + $0.onTapGesture { + hideKeyboard() + } + .onLongPressGesture(minimumDuration: .infinity, perform: {}, onPressingChanged: { pressing in + touchInProgress = pressing + }) + } } - private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { - let im = ItemsModel.shared - return range.compactMap { i in - if i >= 0 && i < im.reversedChatItems.count { - let ci = im.reversedChatItems[i] - return if ci.isRcvNew { ci.id } else { nil } - } else { - return nil + private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { + var unreadItems: [ChatItem.ID] = [] + var unreadMentions: Int = 0 + + for i in range { + if i < 0 || i >= im.reversedChatItems.count { + break + } + let ci = im.reversedChatItems[i] + if ci.isRcvNew { + unreadItems.append(ci.id) + if ci.meta.userMention { + unreadMentions += 1 + } } } + + return (unreadItems, unreadMentions) } - + private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { - Task { - _ = try? await Task.sleep(nanoseconds: 600_000000) - if m.chatId == chat.chatInfo.id { - await op() + markReadTask = Task { + do { + _ = try await Task.sleep(nanoseconds: 600_000000) + if m.chatId == chat.chatInfo.id { + await op() + } + } catch { + // task was cancelled } } } - + + private var searchIsNotBlank: Bool { + get { + searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } @available(iOS 16.0, *) struct MemberLayout: Layout { @@ -1072,20 +1490,25 @@ struct ChatView: View { } } - @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View { + @ViewBuilder func chatItemListView( + _ range: ClosedRange?, + _ showAvatar: Bool, + _ ci: ChatItem, + _ itemSeparation: ItemSeparation + ) -> some View { let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2 if case let .groupRcv(member) = ci.chatDir, - case let .group(groupInfo) = chat.chatInfo { - let (prevMember, memCount): (GroupMember?, Int) = - if let range = range { - m.getPrevHiddenMember(member, range) - } else { - (nil, 1) - } - if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { + case .group = chat.chatInfo { + if showAvatar { VStack(alignment: .leading, spacing: 4) { if ci.content.showMemberName { Group { + let (prevMember, memCount): (GroupMember?, Int) = + if let range = range { + m.getPrevHiddenMember(member, range) + } else { + (nil, 1) + } if memCount == 1 && member.memberRole > .member { Group { if #available(iOS 16.0, *) { @@ -1129,7 +1552,7 @@ struct ChatView: View { } HStack(alignment: .top, spacing: 10) { MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { if let mem = m.getGroupMember(member.groupMemberId) { selectedMember = mem } else { @@ -1138,7 +1561,7 @@ struct ChatView: View { m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1 selectedMember = mem } - } + }) chatItemWithMenu(ci, range, maxWidth, itemSeparation) .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } } @@ -1188,20 +1611,31 @@ struct ChatView: View { } } - @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { + func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - VStack(alignment: alignment.horizontal, spacing: 3) { - ChatItemView( - chat: chat, - chatItem: ci, - maxWidth: maxWidth, - allowMenu: $allowMenu - ) - .environment(\.revealed, revealed) - .environment(\.showTimestamp, itemSeparation.timestamp) - .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) - .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } - .accessibilityLabel("") + return VStack(alignment: alignment.horizontal, spacing: 3) { + HStack { + if ci.chatDir.sent { + goToItemButton(true) + } + ChatItemView( + chat: chat, + im: im, + chatItem: ci, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, + maxWidth: maxWidth, + allowMenu: $allowMenu + ) + .environment(\.revealed, revealed) + .environment(\.showTimestamp, itemSeparation.timestamp) + .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) + .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } + .accessibilityLabel("") + if !ci.chatDir.sent { + goToItemButton(false) + } + } if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { chatItemReactions(ci) .padding(.bottom, 4) @@ -1222,12 +1656,28 @@ struct ChatView: View { deleteMessages(chat, deletingItems, moderate: false) } } + .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { + Button("For me", role: .destructive) { + if let reports = self.archivingReports { + archiveReports(chat.chatInfo, reports.sorted(), false) + self.archivingReports = [] + } + } + if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { + Button("For all moderators", role: .destructive) { + if let reports = self.archivingReports { + archiveReports(chat.chatInfo, reports.sorted(), true) + self.archivingReports = [] + } + } + } + } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { chatItemInfo = nil }) { - ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo) + ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo) } } @@ -1257,12 +1707,12 @@ struct ChatView: View { .padding(.horizontal, 6) .padding(.vertical, 4) .if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in - v.onTapGesture { + v.simultaneousGesture(TapGesture().onEnded { setReaction(ci, add: !r.userReacted, reaction: r.reaction) - } + }) } switch chat.chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): v.contextMenu { ReactionContextMenu( groupInfo: groupInfo, @@ -1285,7 +1735,7 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { + if case let .group(gInfo, _) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator { archiveReportButton(ci) } @@ -1343,9 +1793,13 @@ struct ChatView: View { if ci.chatDir != .groupSnd { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) - } // else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { - // reportButton(ci) - // } + } else if ci.meta.itemDeleted == nil && chat.groupFeatureEnabled(.reports), + case let .group(gInfo, _) = chat.chatInfo, + gInfo.membership.memberRole == .member + && !live + && composeState.voiceMessageRecordingState == .noRecording { + reportButton(ci) + } } } else if ci.meta.itemDeleted != nil { if revealed { @@ -1451,6 +1905,7 @@ struct ChatView: View { let chatItem = try await apiChatItemReaction( type: cInfo.chatType, id: cInfo.apiId, + scope: cInfo.groupChatScope(), itemId: ci.id, add: add, reaction: reaction @@ -1564,11 +2019,11 @@ struct ChatView: View { Task { do { let cInfo = chat.chatInfo - let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, itemId: ci.id) + let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id) await MainActor.run { chatItemInfo = ciInfo } - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { await m.loadGroupMembers(gInfo) } } catch let error { @@ -1609,7 +2064,7 @@ struct ChatView: View { private func hideButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = nil + reveal(false) } } label: { Label( @@ -1622,13 +2077,13 @@ struct ChatView: View { private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button { Button(role: .destructive) { if !revealed, - let currIndex = m.getChatItemIndex(ci), + let currIndex = m.getChatItemIndex(im, ci), let ciCategory = ci.mergeCategory { let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) if let range = itemsRange(currIndex, prevHidden) { var itemIds: [Int64] = [] for i in range { - itemIds.append(ItemsModel.shared.reversedChatItems[i].id) + itemIds.append(im.reversedChatItems[i].id) } showDeleteMessages = true deletingItems = itemIds @@ -1677,20 +2132,11 @@ struct ChatView: View { ) } } - + private func archiveReportButton(_ cItem: ChatItem) -> Button { - Button(role: .destructive) { - AlertManager.shared.showAlert( - Alert( - title: Text("Archive report?"), - message: Text("The report will be archived for you."), - primaryButton: .destructive(Text("Archive")) { - deletingItem = cItem - deleteMessage(.cidmInternalMark, moderate: false) - }, - secondaryButton: .cancel() - ) - ) + Button { + archivingReports = [cItem.id] + showArchivingReports = true } label: { Label("Archive report", systemImage: "archivebox") } @@ -1699,7 +2145,7 @@ struct ChatView: View { private func revealButton(_ ci: ChatItem) -> Button { Button { withConditionalAnimation { - revealedChatItem = ci + reveal(true) } } label: { Label( @@ -1712,7 +2158,7 @@ struct ChatView: View { private func expandButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = chatItem + reveal(true) } } label: { Label( @@ -1725,7 +2171,7 @@ struct ChatView: View { private func shrinkButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = nil + reveal(false) } } label: { Label ( @@ -1734,7 +2180,7 @@ struct ChatView: View { ) } } - + private func reportButton(_ ci: ChatItem) -> Button { Button(role: .destructive) { var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in @@ -1748,9 +2194,9 @@ struct ChatView: View { } } } - + buttons.append(.cancel()) - + actionSheet = SomeActionSheet( actionSheet: ActionSheet( title: Text("Report reason?"), @@ -1765,7 +2211,7 @@ struct ChatView: View { ) } } - + var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" @@ -1775,12 +2221,12 @@ struct ChatView: View { selectedChatItems = selectedChatItems ?? [] var itemIds: [Int64] = [] if !revealed, - let currIndex = m.getChatItemIndex(ci), + let currIndex = m.getChatItemIndex(im, ci), let ciCategory = ci.mergeCategory { let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) if let range = itemsRange(currIndex, prevHidden) { for i in range { - itemIds.append(ItemsModel.shared.reversedChatItems[i].id) + itemIds.append(im.reversedChatItems[i].id) } } else { itemIds.append(ci.id) @@ -1814,6 +2260,7 @@ struct ChatView: View { try await apiDeleteChatItems( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemIds: [di.id], mode: mode ) @@ -1830,6 +2277,7 @@ struct ChatView: View { if deletedItem.isActiveReport { m.decreaseGroupReportsCounter(chat.chatInfo.id) } + m.updateChatInfo(itemDeletion.deletedChatItem.chatInfo) } } } @@ -1838,7 +2286,7 @@ struct ChatView: View { } } } - + @ViewBuilder private func contactReactionMenu(_ contact: Contact, _ r: CIReactionCount) -> some View { if !r.userReacted || r.totalReacted > 1 { Button { showChatInfoSheet = true } label: { @@ -1853,6 +2301,34 @@ struct ChatView: View { } } + func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View { + Image(systemName: image) + .resizable() + .frame(width: 13, height: 13) + .padding([alignStart ? .trailing : .leading], 10) + .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4)) + .simultaneousGesture(TapGesture().onEnded(onClick)) + } + + @ViewBuilder + func goToItemButton(_ alignStart: Bool) -> some View { + let chatTypeApiIdMsgId = chatItem.meta.itemForwarded?.chatTypeApiIdMsgId + if searchIsNotBlank { + goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) { + closeKeyboardAndRun { + im.loadOpenChatNoWait(chat.id, chatItem.id) + } + } + } else if let chatTypeApiIdMsgId { + goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) { + closeKeyboardAndRun { + let (chatType, apiId, msgId) = chatTypeApiIdMsgId + im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + } + } + } + } + private struct SelectedChatItem: View { @EnvironmentObject var theme: AppTheme var ciId: Int64 @@ -1874,6 +2350,84 @@ struct ChatView: View { } } +class FloatingButtonModel: ObservableObject { + @ObservedObject var im: ItemsModel + + public init(im: ItemsModel) { + self.im = im + } + + @Published var unreadAbove: Int = 0 + @Published var unreadBelow: Int = 0 + @Published var isNearBottom: Bool = true + @Published var date: Date? = nil + @Published var isDateVisible: Bool = false + var hideDateWorkItem: DispatchWorkItem? = nil + + func updateOnListChange(_ listState: EndlessScrollView.ListState) { + let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState) + let unreadBelow = if let lastVisibleItem { + max(0, im.chatState.unreadTotal - lastVisibleItem.unreadBefore) + } else { + 0 + } + let unreadAbove = im.chatState.unreadTotal - unreadBelow + let date: Date? = + if let lastVisible = listState.visibleItems.last { + Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs) + } else { + nil + } + + // set the counters and date indicator + DispatchQueue.main.async { [weak self] in + guard let it = self else { return } + it.setDate(visibility: true) + it.unreadAbove = unreadAbove + it.unreadBelow = unreadBelow + it.date = date + } + + // set floating button indication mode + let nearBottom = listState.firstVisibleItemIndex < 1 + if nearBottom != self.isNearBottom { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.isNearBottom = nearBottom + } + } + + // hide Date indicator after 1 second of no scrolling + hideDateWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let it = self else { return } + it.setDate(visibility: false) + it.hideDateWorkItem = nil + } + DispatchQueue.main.async { [weak self] in + self?.hideDateWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) + } + } + + func resetDate() { + date = nil + isDateVisible = false + } + + private func setDate(visibility isVisible: Bool) { + if isVisible { + if !isNearBottom, + !isDateVisible, + let date, !Calendar.current.isDateInToday(date) { + withAnimation { self.isDateVisible = true } + } + } else if isDateVisible { + withAnimation { self.isDateVisible = false } + } + } + +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } @@ -1895,6 +2449,7 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe try await apiDeleteChatItems( type: chatInfo.chatType, id: chatInfo.apiId, + scope: chatInfo.groupChatScope(), itemIds: itemIds, mode: mode ) @@ -1912,6 +2467,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id) } } + if let updatedChatInfo = deletedItems.last?.deletedChatItem.chatInfo { + ChatModel.shared.updateChatInfo(updatedChatInfo) + } } await onSuccess() } catch { @@ -1921,11 +2479,45 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe } } +func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) { + if itemIds.count > 0 { + Task { + do { + let deleted = try await apiDeleteReceivedReports( + groupId: chatInfo.apiId, + itemIds: itemIds, + mode: forAll ? CIDeleteMode.cidmBroadcast : CIDeleteMode.cidmInternalMark + ) + + await MainActor.run { + for di in deleted { + if let toItem = di.toChatItem { + _ = ChatModel.shared.upsertChatItem(chatInfo, toItem.chatItem) + } else { + ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) + } + let deletedItem = di.deletedChatItem.chatItem + if deletedItem.isActiveReport { + ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id) + } + } + if let updatedChatInfo = deleted.last?.deletedChatItem.chatInfo { + ChatModel.shared.updateChatInfo(updatedChatInfo) + } + } + await onSuccess() + } catch { + logger.error("ChatView.archiveReports error: \(error.localizedDescription)") + } + } + } +} + private func buildTheme() -> AppTheme { if let cId = ChatModel.shared.chatId, let chat = ChatModel.shared.getChat(cId) { let perChatTheme = if case let .direct(contact) = chat.chatInfo { contact.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight) - } else if case let .group(groupInfo) = chat.chatInfo { + } else if case let .group(groupInfo, _) = chat.chatInfo { groupInfo.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight) } else { nil as ThemeModeOverride? @@ -1961,7 +2553,7 @@ struct ReactionContextMenu: View { @ViewBuilder private func groupMemberReactionList() -> some View { if memberReactions.isEmpty { ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in - Text(verbatim: " ") + textSpace } } else { ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in @@ -2044,21 +2636,19 @@ struct ToggleNtfsButton: View { @ObservedObject var chat: Chat var body: some View { - Button { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) - } label: { - if chat.chatInfo.ntfsEnabled { - Label("Mute", systemImage: "speaker.slash") - } else { - Label("Unmute", systemImage: "speaker.wave.2") + if let nextMode = chat.chatInfo.nextNtfMode { + Button { + toggleNotifications(chat, enableNtfs: nextMode) + } label: { + Label(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.icon) } } } } -func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { +func toggleNotifications(_ chat: Chat, enableNtfs: MsgFilter) { var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults - chatSettings.enableNtfs = enableNtfs ? .all : .none + chatSettings.enableNtfs = enableNtfs updateChatSettings(chat, chatSettings: chatSettings) } @@ -2080,7 +2670,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { case var .direct(contact): contact.chatSettings = chatSettings ChatModel.shared.updateContact(contact) - case var .group(groupInfo): + case var .group(groupInfo, _): groupInfo.chatSettings = chatSettings ChatModel.shared.updateGroup(groupInfo) default: () @@ -2097,7 +2687,8 @@ struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.chatId = "@1" - ItemsModel.shared.reversedChatItems = [ + let im = ItemsModel.shared + im.reversedChatItems = [ ChatItem.getSample(1, .directSnd, .now, "hello"), ChatItem.getSample(2, .directRcv, .now, "hi"), ChatItem.getSample(3, .directRcv, .now, "hi there"), @@ -2109,7 +2700,13 @@ struct ChatView_Previews: PreviewProvider { ChatItem.getSample(9, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") ] @State var showChatInfo = false - return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) - .environmentObject(chatModel) + return ChatView( + chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im), + scrollToItemId: Binding.constant(nil) + ) + .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift index 6c44aeea83..e629a984df 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift @@ -18,7 +18,7 @@ struct ComposeLinkView: View { var body: some View { HStack(alignment: .center, spacing: 8) { - if let linkPreview = linkPreview { + if let linkPreview { linkPreviewView(linkPreview) } else { ProgressView() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a68a4987a1..68a2f6d7b1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -11,6 +11,8 @@ import SimpleXChat import SwiftyGif import PhotosUI +let MAX_NUMBER_OF_MENTIONS = 3 + enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) @@ -19,7 +21,7 @@ enum ComposePreview { case filePreview(fileName: String, file: URL) } -enum ComposeContextItem { +enum ComposeContextItem: Equatable { case noContextItem case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) @@ -39,31 +41,41 @@ struct LiveMessage { var sentMsg: String? } +typealias MentionedMembers = [String: CIMention] + struct ComposeState { var message: String + var parsedMessage: [FormattedText] var liveMessage: LiveMessage? = nil var preview: ComposePreview var contextItem: ComposeContextItem var voiceMessageRecordingState: VoiceMessageRecordingState var inProgress = false var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + var mentions: MentionedMembers = [:] init( message: String = "", + parsedMessage: [FormattedText] = [], liveMessage: LiveMessage? = nil, preview: ComposePreview = .noPreview, contextItem: ComposeContextItem = .noContextItem, - voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording + voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording, + mentions: MentionedMembers = [:] ) { self.message = message + self.parsedMessage = parsedMessage self.liveMessage = liveMessage self.preview = preview self.contextItem = contextItem self.voiceMessageRecordingState = voiceMessageRecordingState + self.mentions = mentions } init(editingItem: ChatItem) { - self.message = editingItem.content.text + let text = editingItem.content.text + self.message = text + self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text) self.preview = chatItemPreview(chatItem: editingItem) self.contextItem = .editingItem(chatItem: editingItem) if let emc = editingItem.content.msgContent, @@ -72,10 +84,12 @@ struct ComposeState { } else { self.voiceMessageRecordingState = .noRecording } + self.mentions = editingItem.mentions ?? [:] } init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) { self.message = "" + self.parsedMessage = [] self.preview = .noPreview self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo) self.voiceMessageRecordingState = .noRecording @@ -83,20 +97,38 @@ struct ComposeState { func copy( message: String? = nil, + parsedMessage: [FormattedText]? = nil, liveMessage: LiveMessage? = nil, preview: ComposePreview? = nil, contextItem: ComposeContextItem? = nil, - voiceMessageRecordingState: VoiceMessageRecordingState? = nil + voiceMessageRecordingState: VoiceMessageRecordingState? = nil, + mentions: MentionedMembers? = nil ) -> ComposeState { ComposeState( message: message ?? self.message, + parsedMessage: parsedMessage ?? self.parsedMessage, liveMessage: liveMessage ?? self.liveMessage, preview: preview ?? self.preview, contextItem: contextItem ?? self.contextItem, - voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState + voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState, + mentions: mentions ?? self.mentions ) } - + + func mentionMemberName(_ name: String) -> String { + var n = 0 + var tryName = name + while mentions[tryName] != nil { + n += 1 + tryName = "\(name)_\(n)" + } + return tryName + } + + var memberMentions: [String: Int64] { + self.mentions.compactMapValues { $0.memberRef?.groupMemberId } + } + var editing: Bool { switch contextItem { case .editingItem: return true @@ -291,8 +323,12 @@ struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date + @Binding var selectedRange: NSRange + var disabledText: LocalizedStringKey? = nil @State var linkUrl: URL? = nil @State var hasSimplexLink: Bool = false @@ -321,6 +357,20 @@ struct ComposeView: View { var body: some View { VStack(spacing: 0) { Divider() + if let groupInfo = chat.chatInfo.groupInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(member) = groupScopeInfo, + let member = member, + member.memberPending, + composeState.contextItem == .noContextItem, + composeState.noPreview { + ContextPendingMemberActionsView( + groupInfo: groupInfo, + member: member + ) + Divider() + } + if chat.chatInfo.contact?.nextSendGrpInv ?? false { ContextInvitingContactMemberView() Divider() @@ -331,8 +381,8 @@ struct ComposeView: View { Divider() } // preference checks should match checks in forwarding list - let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) - let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) + let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) + let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) if simplexLinkProhibited { msgNotAllowedView("SimpleX links not allowed", icon: "link") @@ -357,12 +407,13 @@ struct ComposeView: View { Image(systemName: "paperclip") .resizable() } - .disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) + .disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) .frame(width: 25, height: 25) - .padding(.bottom, 12) + .padding(.bottom, 16) .padding(.leading, 12) .tint(theme.colors.primary) - if case let .group(g) = chat.chatInfo, + if im.secondaryIMFilter == nil, + case let .group(g, _) = chat.chatInfo, !g.fullGroupPreferences.files.on(for: g.membership) { b.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( @@ -376,6 +427,7 @@ struct ComposeView: View { ZStack(alignment: .leading) { SendMessageView( composeState: $composeState, + selectedRange: $selectedRange, sendMessage: { ttl in sendMessage(ttl: ttl) resetLinkPreview() @@ -400,24 +452,19 @@ struct ComposeView: View { timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, sendButtonColor: chat.chatInfo.incognito - ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) - : theme.colors.primary + ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) + : theme.colors.primary ) .padding(.trailing, 12) - .disabled(!chat.userCanSend) + .disabled(!chat.chatInfo.sendMsgEnabled) - if chat.userIsObserver { - Text("you are observer") + if let disabledText { + Text(disabledText) .italic() .foregroundColor(theme.colors.secondary) .padding(.horizontal, 12) - .onTapGesture { - AlertManager.shared.showAlertMsg( - title: "You can't send messages!", - message: "Please contact group admin." - ) - } } } } @@ -428,21 +475,23 @@ struct ComposeView: View { .ignoresSafeArea(.all, edges: .bottom) } .onChange(of: composeState.message) { msg in + let parsedMsg = parseSimpleXMarkdown(msg) + composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg)) if composeState.linkPreviewAllowed { if msg.count > 0 { - showLinkPreview(msg) + showLinkPreview(parsedMsg) } else { resetLinkPreview() hasSimplexLink = false } } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) { - (_, hasSimplexLink) = parseMessage(msg) + (_, hasSimplexLink) = getSimplexLink(parsedMsg) } else { hasSimplexLink = false } } - .onChange(of: chat.userCanSend) { canSend in - if !canSend { + .onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in + if !sendEnabled { cancelCurrentVoiceRecording() clearCurrentDraft() clearState() @@ -793,6 +842,7 @@ struct ComposeView: View { var sent: ChatItem? let msgText = text ?? composeState.message let liveMessage = composeState.liveMessage + let mentions = composeState.memberMentions if !live { if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) } await sending() @@ -803,7 +853,7 @@ struct ComposeView: View { // Composed text is send as a reply to the last forwarded item sent = await forwardItems(chatItems, fromChatInfo, ttl).last if !composeState.message.isEmpty { - _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) + _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions) } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) @@ -819,10 +869,11 @@ struct ComposeView: View { switch (composeState.preview) { case .noPreview: - sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) + sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case .linkPreview: - sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) + sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case let .mediaPreviews(media): + // TODO: CHECK THIS let last = media.count - 1 var msgs: [ComposedMessage] = [] if last >= 0 { @@ -847,10 +898,10 @@ struct ComposeView: View { case let .voicePreview(recordingFileName, duration): stopPlayback.toggle() let file = voiceCryptoFile(recordingFileName) - sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) + sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions) case let .filePreview(_, file): if let savedFile = saveFileFromURL(file) { - sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) + sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions) } } } @@ -904,8 +955,9 @@ struct ComposeView: View { let chatItem = try await apiUpdateChatItem( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemId: ei.id, - msg: mc, + updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions), live: live ) await MainActor.run { @@ -966,9 +1018,9 @@ struct ComposeView: View { reportReason: reportReason, reportText: msgText ) { - await MainActor.run { - for chatItem in chatItems { - chatModel.addChatItem(chat.chatInfo, chatItem) + if showReportsInSupportChatAlertDefault.get() { + await MainActor.run { + showReportsInSupportChatAlert() } } return chatItems.first @@ -976,10 +1028,30 @@ struct ComposeView: View { return nil } - - func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { + + func showReportsInSupportChatAlert() { + showAlert( + NSLocalizedString("Report sent to moderators", comment: "alert title"), + message: NSLocalizedString("You can view your reports in Chat with admins.", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Don't show again", comment: "alert action"), + style: .default, + handler: { _ in + showReportsInSupportChatAlertDefault.set(false) + } + ), + UIAlertAction( + title: NSLocalizedString("Ok", comment: "alert action"), + style: .default + ) + ]} + ) + } + + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? { await send( - [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], + [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)], live: live, ttl: ttl ).first @@ -991,6 +1063,7 @@ struct ComposeView: View { : await apiSendMessages( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), live: live, ttl: ttl, composedMessages: msgs @@ -1015,8 +1088,10 @@ struct ComposeView: View { if let chatItems = await apiForwardChatItems( toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, + toScope: chat.chatInfo.groupChatScope(), fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, + fromScope: fromChatInfo.groupChatScope(), itemIds: forwardedItems.map { $0.id }, ttl: ttl ) { @@ -1043,7 +1118,8 @@ struct ComposeView: View { func checkLinkPreview() -> MsgContent { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): - if let url = parseMessage(msgText).url, + if let parsedMsg = parseSimpleXMarkdown(msgText), + let url = getSimplexLink(parsedMsg).url, let linkPreview = linkPreview, url == linkPreview.uri { return .link(text: msgText, preview: linkPreview) @@ -1162,9 +1238,9 @@ struct ComposeView: View { } } - private func showLinkPreview(_ s: String) { + private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl - (linkUrl, hasSimplexLink) = parseMessage(s) + (linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg) if let url = linkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl { pendingLinkUrl = url @@ -1181,8 +1257,8 @@ struct ComposeView: View { } } - private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) { - guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) } + private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) { + guard let parsedMsg else { return (nil, false) } let url: URL? = if let uri = parsedMsg.first(where: { ft in ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }) { @@ -1213,11 +1289,14 @@ struct ComposeView: View { if pendingLinkUrl == url { composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) getLinkPreview(url: url) { linkPreview in - if let linkPreview = linkPreview, - pendingLinkUrl == url { + if let linkPreview, pendingLinkUrl == url { composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) - pendingLinkUrl = nil + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + composeState = composeState.copy(preview: .noPreview) + } } + pendingLinkUrl = nil } } } @@ -1233,19 +1312,27 @@ struct ComposeView: View { struct ComposeView_Previews: PreviewProvider { static var previews: some View { let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) + let im = ItemsModel.shared @State var composeState = ComposeState(message: "hello") + @State var selectedRange = NSRange() return Group { ComposeView( chat: chat, + im: im, composeState: $composeState, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), + selectedRange: $selectedRange ) .environmentObject(ChatModel()) ComposeView( chat: chat, + im: im, composeState: $composeState, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), + selectedRange: $selectedRange ) .environmentObject(ChatModel()) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 3cb747ec68..845442c75f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -70,8 +70,10 @@ struct ContextItemView: View { .lineLimit(lines) } - private func contextMsgPreview(_ contextItem: ChatItem) -> Text { - return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + private func contextMsgPreview(_ contextItem: ChatItem) -> some View { + let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background)) + let t = attachment() + Text(AttributedString(r.string)) + return t.if(r.hasSecrets, transform: hiddenSecretsView) func attachment() -> Text { let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift new file mode 100644 index 0000000000..96915b342f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -0,0 +1,108 @@ +// +// ContextPendingMemberActionsView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 02.05.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ContextPendingMemberActionsView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) var dismiss + var groupInfo: GroupInfo + var member: GroupMember + + var body: some View { + HStack(spacing: 0) { + ZStack { + Text("Reject") + .foregroundColor(.red) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + showRejectMemberAlert(groupInfo, member, dismiss: dismiss) + } + + ZStack { + Text("Accept") + .foregroundColor(theme.colors.primary) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + showAcceptMemberAlert(groupInfo, member, dismiss: dismiss) + } + } + .frame(minHeight: 54) + .frame(maxWidth: .infinity) + .background(.thinMaterial) + } +} + +func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { + showAlert( + title: NSLocalizedString("Reject member?", comment: "alert title"), + buttonTitle: "Reject", + buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + cancelButton: true + ) +} + +func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { + showAlert( + NSLocalizedString("Accept member", comment: "alert title"), + message: NSLocalizedString("Member will join the group, accept member?", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Accept as member", comment: "alert action"), + style: .default, + handler: { _ in + acceptMember(groupInfo, member, .member, dismiss: dismiss) + } + ), + UIAlertAction( + title: NSLocalizedString("Accept as observer", comment: "alert action"), + style: .default, + handler: { _ in + acceptMember(groupInfo, member, .observer, dismiss: dismiss) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default + ) + ]} + ) +} + +func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole, dismiss: DismissAction? = nil) { + Task { + do { + let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, acceptedMember) + ChatModel.shared.updateGroup(gInfo) + dismiss?() + } + } catch let error { + logger.error("apiAcceptMember error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting member", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + +#Preview { + ContextPendingMemberActionsView( + groupInfo: GroupInfo.sampleData, + member: GroupMember.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 2fc122f249..d809fd7b76 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,19 +16,15 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var lastUnfocusedDate: Date @Binding var placeholder: String? + @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void - private let minHeight: CGFloat = 37 + static let minHeight: CGFloat = 39 - private let defaultHeight: CGFloat = { - let field = CustomUITextField(height: Binding.constant(0)) - field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) - return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) - }() - - func makeUIView(context: Context) -> UITextView { - let field = CustomUITextField(height: _height) + func makeUIView(context: Context) -> CustomUITextField { + let field = CustomUITextField(parent: self, height: _height) field.backgroundColor = .clear field.text = text field.textAlignment = alignment(text) @@ -37,10 +33,9 @@ struct NativeTextEditor: UIViewRepresentable { if !disableEditing { text = newText field.textAlignment = alignment(text) - updateFont(field) + field.updateFont() // Speed up the process of updating layout, reduce jumping content on screen - updateHeight(field) - self.height = field.frame.size.height + field.updateHeight() } else { field.text = text } @@ -48,48 +43,32 @@ struct NativeTextEditor: UIViewRepresentable { onImagesAdded(images) } } - field.setOnFocusChangedListener { focused = $0 } + field.setOnFocusChangedListener { + focused = $0 + if !focused { + lastUnfocusedDate = .now + } + } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.setPlaceholderView() - updateFont(field) - updateHeight(field) + field.updateFont() + field.updateHeight(updateBindingNow: false) return field } - func updateUIView(_ field: UITextView, context: Context) { + func updateUIView(_ field: CustomUITextField, context: Context) { if field.markedTextRange == nil && field.text != text { field.text = text field.textAlignment = alignment(text) - updateFont(field) - updateHeight(field) + field.updateFont() + field.updateHeight(updateBindingNow: false) } - - let castedField = field as! CustomUITextField - if castedField.placeholder != placeholder { - castedField.placeholder = placeholder + if field.placeholder != placeholder { + field.placeholder = placeholder } - } - - private func updateHeight(_ field: UITextView) { - let maxHeight = min(360, field.font!.lineHeight * 12) - // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size - let newHeight = field.text == "" - ? defaultHeight - : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down) - - if field.frame.size.height != newHeight { - field.frame.size = CGSizeMake(field.frame.size.width, newHeight) - (field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight) - } - } - - private func updateFont(_ field: UITextView) { - let newFont = isShortEmoji(field.text) - ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) - : UIFont.preferredFont(forTextStyle: .body) - if field.font != newFont { - field.font = newFont + if field.selectedRange != selectedRange { + field.selectedRange = selectedRange } } } @@ -98,15 +77,17 @@ private func alignment(_ text: String) -> NSTextAlignment { isRightToLeft(text) ? .right : .left } -private class CustomUITextField: UITextView, UITextViewDelegate { +class CustomUITextField: UITextView, UITextViewDelegate { + var parent: NativeTextEditor? var height: Binding var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } - + private let placeholderLabel: UILabel = UILabel() - init(height: Binding) { + init(parent: NativeTextEditor?, height: Binding) { + self.parent = parent self.height = height super.init(frame: .zero, textContainer: nil) } @@ -128,11 +109,44 @@ private class CustomUITextField: UITextView, UITextViewDelegate { invalidateIntrinsicContentSize() } - override var intrinsicContentSize: CGSize { - if height.wrappedValue != newHeight { - DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight }) + func updateHeight(updateBindingNow: Bool = true) { + let maxHeight = min(360, font!.lineHeight * 12) + let newHeight = min(max(sizeThatFits(CGSizeMake(frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down) + + if self.newHeight != newHeight { + frame.size = CGSizeMake(frame.size.width, newHeight) + invalidateIntrinsicContentHeight(newHeight) + if updateBindingNow { + self.height.wrappedValue = newHeight + } else { + DispatchQueue.main.async { + self.height.wrappedValue = newHeight + } + } } - return CGSizeMake(0, newHeight) + } + + func updateFont() { + let newFont = isShortEmoji(text) + ? (text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) + : UIFont.preferredFont(forTextStyle: .body) + if font != newFont { + font = newFont + // force apply new font because it has problem with doing it when the field had two emojis + if text.count == 0 { + text = " " + text = "" + } + } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateHeight() + } + + override var intrinsicContentSize: CGSize { + CGSizeMake(0, newHeight) } func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { @@ -232,10 +246,22 @@ private class CustomUITextField: UITextView, UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { onFocusChanged(true) + updateSelectedRange(textView) } func textViewDidEndEditing(_ textView: UITextView) { onFocusChanged(false) + updateSelectedRange(textView) + } + + func textViewDidChangeSelection(_ textView: UITextView) { + updateSelectedRange(textView) + } + + private func updateSelectedRange(_ textView: UITextView) { + if parent?.selectedRange != textView.selectedRange { + parent?.selectedRange = textView.selectedRange + } } } @@ -246,7 +272,9 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + lastUnfocusedDate: Binding.constant(.now), placeholder: Binding.constant("Placeholder"), + selectedRange: Binding.constant(NSRange(location: 0, length: 0)), onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index fb69dfdd17..e7b02c9aea 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -13,7 +13,9 @@ private let liveMsgInterval: UInt64 = 3000_000000 struct SendMessageView: View { @Binding var composeState: ComposeState + @Binding var selectedRange: NSRange @EnvironmentObject var theme: AppTheme + @Environment(\.isEnabled) var isEnabled var sendMessage: (Int?) -> Void var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil @@ -31,8 +33,9 @@ struct SendMessageView: View { @State private var holdingVMR = false @Namespace var namespace @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date var sendButtonColor = Color.accentColor - @State private var teHeight: CGFloat = 42 + @State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teFont: Font = .body @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 @@ -43,52 +46,54 @@ struct SendMessageView: View { @UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { - ZStack { - let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) - HStack(alignment: .bottom) { - ZStack(alignment: .leading) { - if case .voicePreview = composeState.preview { - Text("Voice message…") - .font(teFont.italic()) - .multilineTextAlignment(.leading) - .foregroundColor(theme.colors.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } else { - NativeTextEditor( - text: $composeState.message, - disableEditing: $composeState.inProgress, - height: $teHeight, - focused: $keyboardVisible, - placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), - onImagesAdded: onMediaAdded - ) - .allowsTightening(false) - .fixedSize(horizontal: false, vertical: true) - } - } - if progressByTimeout { - ProgressView() - .scaleEffect(1.4) - .frame(width: 31, height: 31, alignment: .center) - .padding([.bottom, .trailing], 3) - } else { - VStack(alignment: .trailing) { - if teHeight > 100 && !composeState.inProgress { - deleteTextButton() - Spacer() - } - composeActionButtons() - } - .frame(height: teHeight, alignment: .bottom) - } + let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) + ZStack(alignment: .leading) { + if case .voicePreview = composeState.preview { + Text("Voice message…") + .font(teFont.italic()) + .multilineTextAlignment(.leading) + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .padding(.trailing, 32) + .frame(maxWidth: .infinity) + } else { + NativeTextEditor( + text: $composeState.message, + disableEditing: $composeState.inProgress, + height: $teHeight, + focused: $keyboardVisible, + lastUnfocusedDate: $keyboardHiddenDate, + placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), + selectedRange: $selectedRange, + onImagesAdded: onMediaAdded + ) + .padding(.trailing, 32) + .allowsTightening(false) + .fixedSize(horizontal: false, vertical: true) } - .padding(.vertical, 1) - .background(theme.colors.background) - .clipShape(composeShape) - .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) } + .overlay(alignment: .topTrailing, content: { + if !progressByTimeout && teHeight > 100 && !composeState.inProgress { + deleteTextButton() + } + }) + .overlay(alignment: .bottomTrailing, content: { + if progressByTimeout { + ProgressView() + .scaleEffect(1.4) + .frame(width: 31, height: 31, alignment: .center) + .padding([.bottom, .trailing], 4) + } else { + composeActionButtons() + // required for intercepting clicks + .background(.white.opacity(0.000001)) + } + }) + .padding(.vertical, 1) + .background(theme.colors.background) + .clipShape(composeShape) + .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) .onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.inProgress) { inProgress in if inProgress { @@ -167,7 +172,7 @@ struct SendMessageView: View { !composeState.sendEnabled || composeState.inProgress ) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) } @@ -190,7 +195,7 @@ struct SendMessageView: View { composeState.endLiveDisabled || disableSendButton ) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .contextMenu{ sendButtonContextMenuItems() } @@ -251,6 +256,7 @@ struct SendMessageView: View { } private struct RecordVoiceMessageButton: View { + @Environment(\.isEnabled) var isEnabled @EnvironmentObject var theme: AppTheme var startVoiceMessageRecording: (() -> Void)? var finishVoiceMessageRecording: (() -> Void)? @@ -259,15 +265,14 @@ struct SendMessageView: View { @State private var pressed: TimeInterval? = nil var body: some View { - Button(action: {}) { - Image(systemName: "mic.fill") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .foregroundColor(theme.colors.primary) - } + Image(systemName: isEnabled ? "mic.fill" : "mic") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary) + .opacity(holdingVMR ? 0.7 : 1) .disabled(disabled) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) ._onButtonGesture { down in if down { @@ -275,9 +280,7 @@ struct SendMessageView: View { pressed = ProcessInfo.processInfo.systemUptime startVoiceMessageRecording?() } else { - let now = ProcessInfo.processInfo.systemUptime - if let pressed = pressed, - now - pressed >= 1 { + if let pressed, ProcessInfo.processInfo.systemUptime - pressed >= 1 { finishVoiceMessageRecording?() } holdingVMR = false @@ -323,7 +326,7 @@ struct SendMessageView: View { .foregroundColor(theme.colors.secondary) } .disabled(composeState.inProgress) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) } @@ -351,7 +354,7 @@ struct SendMessageView: View { Image(systemName: "bolt.fill") .resizable() .scaledToFit() - .foregroundColor(theme.colors.primary) + .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary) .frame(width: 20, height: 20) } .frame(width: 29, height: 29) @@ -408,7 +411,7 @@ struct SendMessageView: View { .foregroundColor(theme.colors.primary) } .disabled(composeState.inProgress) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) } @@ -424,8 +427,10 @@ struct SendMessageView: View { struct SendMessageView_Previews: PreviewProvider { static var previews: some View { @State var composeStateNew = ComposeState() + @State var selectedRange = NSRange() let ci = ChatItem.getSample(1, .directSnd, .now, "hello") @State var composeStateEditing = ComposeState(editingItem: ci) + @State var selectedRangeEditing = NSRange() @State var sendEnabled: Bool = true return Group { @@ -434,9 +439,11 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateNew, + selectedRange: $selectedRange, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } VStack { @@ -444,9 +451,11 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateEditing, + selectedRange: $selectedRangeEditing, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } } diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift new file mode 100644 index 0000000000..cc61754b26 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -0,0 +1,715 @@ +// +// EndlessScrollView.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 25.01.2025. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ScrollRepresentable: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable { + + let scrollView: EndlessScrollView + let content: (Int, ScrollItem) -> Content + + func makeUIViewController(context: Context) -> ScrollController { + ScrollController.init(scrollView: scrollView, content: content) + } + + func updateUIViewController(_ controller: ScrollController, context: Context) {} + + class ScrollController: UIViewController { + let scrollView: EndlessScrollView + fileprivate var items: [ScrollItem] = [] + fileprivate var content: ((Int, ScrollItem) -> Content)! + + fileprivate init(scrollView: EndlessScrollView, content: @escaping (Int, ScrollItem) -> Content) { + self.scrollView = scrollView + self.content = content + super.init(nibName: nil, bundle: nil) + self.view = scrollView + scrollView.createCell = createCell + scrollView.updateCell = updateCell + } + + required init?(coder: NSCoder) { fatalError() } + + private func createCell(_ index: Int, _ items: [ScrollItem], _ cellsToReuse: inout [UIView]) -> UIView { + let item: ScrollItem? = index >= 0 && index < items.count ? items[index] : nil + let cell: UIView + if #available(iOS 16.0, *), false { + let c: UITableViewCell = cellsToReuse.isEmpty ? UITableViewCell() : cellsToReuse.removeLast() as! UITableViewCell + if let item { + c.contentConfiguration = UIHostingConfiguration { self.content(index, item) } + .margins(.all, 0) + .minSize(height: 1) // Passing zero will result in system default of 44 points being used + } + cell = c + } else { + let c = cellsToReuse.isEmpty ? HostingCell() : cellsToReuse.removeLast() as! HostingCell + if let item { + c.set(content: self.content(index, item), parent: self) + } + cell = c + } + cell.isHidden = false + cell.backgroundColor = .clear + let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude)) + cell.frame.size.width = scrollView.bounds.width + cell.frame.size.height = size.height + return cell + } + + private func updateCell(cell: UIView, _ index: Int, _ items: [ScrollItem]) { + let item = items[index] + if #available(iOS 16.0, *), false { + (cell as! UITableViewCell).contentConfiguration = UIHostingConfiguration { self.content(index, item) } + .margins(.all, 0) + .minSize(height: 1) // Passing zero will result in system default of 44 points being used + } else { + if let cell = cell as? HostingCell { + cell.set(content: self.content(index, item), parent: self) + } else { + fatalError("Unexpected Cell Type for: \(item)") + } + } + let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude)) + cell.frame.size.width = scrollView.bounds.width + cell.frame.size.height = size.height + cell.setNeedsLayout() + } + } +} + +class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate where ScrollItem : Identifiable, ScrollItem: Hashable { + + /// Stores actual state of the scroll view and all elements drawn on the screen + let listState: ListState = ListState() + + /// Just some random big number that will probably be enough to scrolling down and up without reaching the end + var initialOffset: CGFloat = 100000000 + + /// Default item id when no items in the visible items list. Something that will never be in real data + fileprivate static var DEFAULT_ITEM_ID: any Hashable { get { Int64.min } } + + /// Storing an offset that was already used for laying down content to be able to see the difference + var prevProcessedOffset: CGFloat = 0 + + /// When screen is being rotated, it's important to track the view size and adjust scroll offset accordingly because the view doesn't know that the content + /// starts from bottom and ends at top, not vice versa as usual + var oldScreenHeight: CGFloat = 0 + + /// Not 100% correct height of the content since the items loaded lazily and their dimensions are unkown until they are on screen + var estimatedContentHeight: ContentHeight = ContentHeight() + + /// Specify here the value that is small enough to NOT see any weird animation when you scroll to items. Minimum expected item size is ok. Scroll speed depends on it too + var averageItemHeight: CGFloat = 30 + + /// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed + var scrollStepMultiplier: CGFloat = 0.37 + + /// Adds content padding to top + var insetTop: CGFloat = 100 + + /// Adds content padding to bottom + var insetBottom: CGFloat = 100 + + var scrollToItemIndexDelayed: Int? = nil + + /// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight] + let scrollBarView: UIScrollView = UIScrollView(frame: .zero) + + /// Stores views that can be used to hold new content so it will be faster to replace something than to create the whole view from scratch + var cellsToReuse: [UIView] = [] + + /// Enable debug to see hundreds of logs + var debug: Bool = false + + var createCell: (Int, [ScrollItem], inout [UIView]) -> UIView? = { _, _, _ in nil } + var updateCell: (UIView, Int, [ScrollItem]) -> Void = { cell, _, _ in } + + + override init(frame: CGRect) { + super.init(frame: frame) + self.delegate = self + } + + required init?(coder: NSCoder) { fatalError() } + + class ListState: NSObject { + + /// Will be called on every change of the items array, visible items, and scroll position + var onUpdateListener: () -> Void = {} + + /// Items that were used to lay out the screen + var items: [ScrollItem] = [] { + didSet { + onUpdateListener() + } + } + + /// It is equai to the number of [items] + var totalItemsCount: Int { + items.count + } + + /// The items with their positions and other useful information. Only those that are visible on screen + var visibleItems: [EndlessScrollView.VisibleItem] = [] + + /// Index in [items] of the first item on screen. This is intentiallty not derived from visible items because it's is used as a starting point for laying out the screen + var firstVisibleItemIndex: Int = 0 + + /// Unique item id of the first visible item on screen + var firstVisibleItemId: any Hashable = EndlessScrollView.DEFAULT_ITEM_ID + + /// Item offset of the first item on screen. Most of the time it's non-positive but it can be positive as well when a user produce overscroll effect on top/bottom of the scroll view + var firstVisibleItemOffset: CGFloat = -100 + + /// Index of the last visible item on screen + var lastVisibleItemIndex: Int { + visibleItems.last?.index ?? 0 + } + + /// Specifies if visible items cover the whole screen or can cover it (if overscrolled) + var itemsCanCoverScreen: Bool = false + + /// Whether there is a non-animated scroll to item in progress or not + var isScrolling: Bool = false + /// Whether there is an animated scroll to item in progress or not + var isAnimatedScrolling: Bool = false + + override init() { + super.init() + } + } + + class VisibleItem { + let index: Int + let item: ScrollItem + let view: UIView + var offset: CGFloat + + init(index: Int, item: ScrollItem, view: UIView, offset: CGFloat) { + self.index = index + self.item = item + self.view = view + self.offset = offset + } + } + + class ContentHeight { + /// After that you should see overscroll effect. When scroll positon is far from + /// top/bottom items, these values are estimated based on items count multiplied by averageItemHeight or real item height (from visible items). Example: + /// [ 10, 9, 8, 7, (6, 5, 4, 3), 2, 1, 0] - 6, 5, 4, 3 are visible and have know heights but others have unknown height and for them averageItemHeight will be used to calculate the whole content height + var topOffsetY: CGFloat = 0 + var bottomOffsetY: CGFloat = 0 + + var virtualScrollOffsetY: CGFloat = 0 + + /// How much distance were overscolled on top which often means to show sticky scrolling that should scroll back to real position after a users finishes dragging the scrollView + var overscrolledTop: CGFloat = 0 + + /// Adds content padding to bottom and top + var inset: CGFloat = 100 + + /// Estimated height of the contents of scroll view + var height: CGFloat { + get { bottomOffsetY - topOffsetY } + } + + /// Estimated height of the contents of scroll view + distance of overscrolled effect. It's only updated when number of item changes to prevent jumping of scroll bar + var virtualOverscrolledHeight: CGFloat { + get { + bottomOffsetY - topOffsetY + overscrolledTop - inset * 2 + } + } + + func update( + _ contentOffset: CGPoint, + _ listState: ListState, + _ averageItemHeight: CGFloat, + _ updateStaleHeight: Bool + ) { + let lastVisible = listState.visibleItems.last + let firstVisible = listState.visibleItems.first + guard let last = lastVisible, let first = firstVisible else { + topOffsetY = contentOffset.y + bottomOffsetY = contentOffset.y + virtualScrollOffsetY = 0 + overscrolledTop = 0 + return + } + topOffsetY = last.view.frame.origin.y - CGFloat(listState.totalItemsCount - last.index - 1) * averageItemHeight - self.inset + bottomOffsetY = first.view.frame.origin.y + first.view.bounds.height + CGFloat(first.index) * averageItemHeight + self.inset + virtualScrollOffsetY = contentOffset.y - topOffsetY + overscrolledTop = max(0, last.index == listState.totalItemsCount - 1 ? last.view.frame.origin.y - contentOffset.y : 0) + } + } + + var topY: CGFloat { + get { contentOffset.y } + } + + var bottomY: CGFloat { + get { contentOffset.y + bounds.height } + } + + override func layoutSubviews() { + super.layoutSubviews() + if contentSize.height == 0 { + setup() + } + let newScreenHeight = bounds.height + if newScreenHeight != oldScreenHeight && oldScreenHeight != 0 { + contentOffset.y += oldScreenHeight - newScreenHeight + scrollBarView.frame = CGRectMake(frame.width - 10, self.insetTop, 10, frame.height - self.insetTop - self.insetBottom) + } + oldScreenHeight = newScreenHeight + adaptItems(listState.items, false) + if let index = scrollToItemIndexDelayed { + scrollToItem(index) + scrollToItemIndexDelayed = nil + } + } + + private func setup() { + contentSize = CGSizeMake(frame.size.width, initialOffset * 2) + prevProcessedOffset = initialOffset + contentOffset = CGPointMake(0, initialOffset) + + showsVerticalScrollIndicator = false + scrollBarView.showsHorizontalScrollIndicator = false + panGestureRecognizer.delegate = self + addGestureRecognizer(scrollBarView.panGestureRecognizer) + superview!.addSubview(scrollBarView) + } + + func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) { + if !Thread.isMainThread { + logger.error("Use main thread to update items") + return + } + if bounds.height == 0 { + self.listState.items = items + // this function requires to have valid bounds and it will be called again once it has them + return + } + adaptItems(items, forceReloadVisible) + snapToContent(animated: false) + } + + /// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes + private func adaptItems(_ items: [ScrollItem], _ forceReloadVisible: Bool, overridenOffset: CGFloat? = nil) { + let start = Date.now + // special case when everything was removed + if items.isEmpty { + listState.visibleItems.forEach { item in item.view.removeFromSuperview() } + listState.visibleItems = [] + listState.itemsCanCoverScreen = false + listState.firstVisibleItemId = EndlessScrollView.DEFAULT_ITEM_ID + listState.firstVisibleItemIndex = 0 + listState.firstVisibleItemOffset = -insetTop + + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) + scrollBarView.contentSize = .zero + scrollBarView.contentOffset = .zero + + prevProcessedOffset = contentOffset.y + // this check is just to prevent didSet listener from firing on the same empty array, no use for this + if !self.listState.items.isEmpty { + self.listState.items = items + } + return + } + + let contentOffsetY = overridenOffset ?? contentOffset.y + + var oldVisible = listState.visibleItems + var newVisible: [VisibleItem] = [] + var visibleItemsHeight: CGFloat = 0 + let offsetsDiff = contentOffsetY - prevProcessedOffset + + var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0 + + var wasFirstVisibleItemOffset = listState.firstVisibleItemOffset + var alreadyChangedIndexWhileScrolling = false + var allowOneMore = false + var nextOffsetY: CGFloat = 0 + var i = shouldBeFirstVisible + // building list of visible items starting from the first one that should be visible + while i >= 0 && i < items.count { + let item = items[i] + let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id }) + let visible: VisibleItem? + if let visibleIndex { + let v = oldVisible.remove(at: visibleIndex) + if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue { + let wasHeight = v.view.bounds.height + updateCell(v.view, i, items) + if wasHeight < v.view.bounds.height && i == 0 && shouldBeFirstVisible == i { + v.view.frame.origin.y -= v.view.bounds.height - wasHeight + } + } + visible = v + } else { + visible = nil + } + if shouldBeFirstVisible == i { + if let vis = visible { + + if // there is auto scroll in progress and the first item has a higher offset than bottom part + // of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to + // re-make the first visible item + (listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || + // the fist visible item previously is hidden now, remove it and move on + !isVisible(vis.view) { + let newIndex: Int + if listState.isAnimatedScrolling { + // skip many items to make the scrolling take less time + var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0 + // if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled + alreadyChangedIndexWhileScrolling = true + + indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff + newIndex = max(0, min(items.count - 1, i + indexDiff)) + // offset for the first visible item can now be 0 because the previous first visible item doesn't exist anymore + wasFirstVisibleItemOffset = 0 + } else { + // don't skip multiple items if it's manual scrolling gesture + newIndex = i + (offsetsDiff <= 0 ? 1 : -1) + } + shouldBeFirstVisible = newIndex + i = newIndex + + cellsToReuse.append(vis.view) + hideAndRemoveFromSuperviewIfNeeded(vis.view) + continue + } + } + let vis: VisibleItem + if let visible { + vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view)) + } else { + let cell = createCell(i, items, &cellsToReuse)! + cell.frame.origin.y = bottomY + wasFirstVisibleItemOffset - cell.frame.height + vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell)) + } + if vis.view.superview == nil { + addSubview(vis.view) + } + newVisible.append(vis) + visibleItemsHeight += vis.view.frame.height + nextOffsetY = vis.view.frame.origin.y + } else { + let vis: VisibleItem + if let visible { + vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view)) + nextOffsetY -= vis.view.frame.height + vis.view.frame.origin.y = nextOffsetY + } else { + let cell = createCell(i, items, &cellsToReuse)! + nextOffsetY -= cell.frame.height + cell.frame.origin.y = nextOffsetY + vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell)) + } + if vis.view.superview == nil { + addSubview(vis.view) + } + newVisible.append(vis) + visibleItemsHeight += vis.view.frame.height + } + if abs(nextOffsetY) < contentOffsetY && !allowOneMore { + break + } else if abs(nextOffsetY) < contentOffsetY { + allowOneMore = false + } + i += 1 + } + if let firstVisible = newVisible.first, firstVisible.view.frame.origin.y + firstVisible.view.frame.height < contentOffsetY + bounds.height, firstVisible.index > 0 { + var offset: CGFloat = firstVisible.view.frame.origin.y + firstVisible.view.frame.height + let index = firstVisible.index + for i in stride(from: index - 1, through: 0, by: -1) { + let item = items[i] + let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id }) + let vis: VisibleItem + if let visibleIndex { + let visible = oldVisible.remove(at: visibleIndex) + visible.view.frame.origin.y = offset + vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view)) + } else { + let cell = createCell(i, items, &cellsToReuse)! + cell.frame.origin.y = offset + vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell)) + } + if vis.view.superview == nil { + addSubview(vis.view) + } + offset += vis.view.frame.height + newVisible.insert(vis, at: 0) + visibleItemsHeight += vis.view.frame.height + if offset >= contentOffsetY + bounds.height { + break + } + } + } + + // removing already unneeded visible items + oldVisible.forEach { vis in + cellsToReuse.append(vis.view) + hideAndRemoveFromSuperviewIfNeeded(vis.view) + } + let itemsCountChanged = listState.items.count != items.count + prevProcessedOffset = contentOffsetY + + listState.visibleItems = newVisible + // bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height). + // For visible items to preserve offset after adding more items having such height is enough + listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop + + listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView.DEFAULT_ITEM_ID + listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0 + listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop + // updating the items with the last step in order to call listener with fully updated state + listState.items = items + + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged) + scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight) + scrollBarView.contentOffset = CGPointMake(0, estimatedContentHeight.virtualScrollOffsetY) + scrollBarView.isHidden = listState.visibleItems.count == listState.items.count && (listState.visibleItems.isEmpty || -listState.firstVisibleItemOffset + (listState.visibleItems.last?.offset ?? 0) + insetTop < bounds.height) + + if debug { + println("time spent \((-start.timeIntervalSinceNow).description.prefix(5).replacingOccurrences(of: "0.000", with: "<0").replacingOccurrences(of: "0.", with: ""))") + } + } + + func setScrollPosition(_ index: Int, _ id: Int64, _ offset: CGFloat = 0) { + listState.firstVisibleItemIndex = index + listState.firstVisibleItemId = id + listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset + } + + func scrollToItem(_ index: Int, top: Bool = true) { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { + return + } + if bounds.height == 0 || contentSize.height == 0 { + scrollToItemIndexDelayed = index + return + } + listState.isScrolling = true + defer { + listState.isScrolling = false + } + + // just a faster way to set top item as requested index + listState.firstVisibleItemIndex = index + listState.firstVisibleItemId = listState.items[index].id + listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom + scrollBarView.flashScrollIndicators() + adaptItems(listState.items, false) + + var adjustedOffset = self.contentOffset.y + var i = 0 + + var upPrev = index > listState.firstVisibleItemIndex + //let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) + + var stepSlowdownMultiplier: CGFloat = 1 + while i < 200 { + let up = index > listState.firstVisibleItemIndex + if upPrev != up { + stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 + upPrev = up + } + + // these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index + let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier + + let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step + adjustedOffset += offsetToScroll + if let item = listState.visibleItems.first(where: { $0.index == index }) { + let y = if top { + min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) + } else { + max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) + } + setContentOffset(CGPointMake(contentOffset.x, y), animated: false) + scrollBarView.flashScrollIndicators() + break + } + contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + adaptItems(listState.items, false) + snapToContent(animated: false) + i += 1 + } + adaptItems(listState.items, false) + snapToContent(animated: false) + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) + } + + func scrollToItemAnimated(_ index: Int, top: Bool = true) async { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { + return + } + listState.isAnimatedScrolling = true + defer { + listState.isAnimatedScrolling = false + } + var adjustedOffset = self.contentOffset.y + var i = 0 + + var upPrev = index > listState.firstVisibleItemIndex + //let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) + + var stepSlowdownMultiplier: CGFloat = 1 + while i < 200 { + let up = index > listState.firstVisibleItemIndex + if upPrev != up { + stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 + upPrev = up + } + + // these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index + let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier + + //println("Scrolling step \(step) \(stepSlowdownMultiplier) index \(index) \(firstOrLastIndex) \(index - firstOrLastIndex) \(adjustedOffset), up \(up), i \(i)") + + let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step + adjustedOffset += offsetToScroll + if let item = listState.visibleItems.first(where: { $0.index == index }) { + let y = if top { + min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) + } else { + max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) + } + setContentOffset(CGPointMake(contentOffset.x, y), animated: true) + scrollBarView.flashScrollIndicators() + break + } + contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + + // skipping unneded relayout if this offset is already processed + if prevProcessedOffset - contentOffset.y != 0 { + adaptItems(listState.items, false) + snapToContent(animated: false) + } + // let UI time to update to see the animated position change + await MainActor.run {} + + i += 1 + } + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) + } + + func scrollToBottom() { + scrollToItem(0, top: false) + } + + func scrollToBottomAnimated() { + Task { + await scrollToItemAnimated(0, top: false) + } + } + + func scroll(by: CGFloat, animated: Bool = true) { + setContentOffset(CGPointMake(contentOffset.x, contentOffset.y + by), animated: animated) + } + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + if !listState.items.isEmpty { + scrollToBottomAnimated() + } + return false + } + + private func snapToContent(animated: Bool) { + let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0 + if topY < estimatedContentHeight.topOffsetY - topBlankSpace { + setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated) + } else if bottomY > estimatedContentHeight.bottomOffsetY { + setContentOffset(CGPointMake(0, estimatedContentHeight.bottomOffsetY - bounds.height), animated: animated) + } + } + + func offsetToBottom(_ view: UIView) -> CGFloat { + bottomY - (view.frame.origin.y + view.frame.height) + } + + /// If I try to .removeFromSuperview() right when I need to remove the view, it is possible to crash the app when the view was hidden in result of + /// pressing Hide in menu on top of the revealed item within the group. So at that point the item should still be attached to the view + func hideAndRemoveFromSuperviewIfNeeded(_ view: UIView) { + if view.isHidden { + // already passed this function + return + } + (view as? ReusableView)?.prepareForReuse() + view.isHidden = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if view.isHidden { view.removeFromSuperview() } + } + } + + /// Synchronizing both scrollViews + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + true + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + snapToContent(animated: true) + } + } + + override var contentOffset: CGPoint { + get { super.contentOffset } + set { + var newOffset = newValue + let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0 + if contentOffset.y > 0 && newOffset.y < estimatedContentHeight.topOffsetY - topBlankSpace && contentOffset.y > newOffset.y { + if !isDecelerating { + newOffset.y = min(contentOffset.y, newOffset.y + abs(newOffset.y - estimatedContentHeight.topOffsetY + topBlankSpace) / 1.8) + } else { + DispatchQueue.main.async { + self.setContentOffset(newValue, animated: false) + self.snapToContent(animated: true) + } + } + } else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y { + if !isDecelerating { + newOffset.y = max(contentOffset.y, newOffset.y - abs(newOffset.y + bounds.height - estimatedContentHeight.bottomOffsetY) / 1.8) + } else { + DispatchQueue.main.async { + self.setContentOffset(newValue, animated: false) + self.snapToContent(animated: true) + } + } + } + super.contentOffset = newOffset + } + } + + private func stopScrolling() { + let offsetYToStopAt = if abs(contentOffset.y - estimatedContentHeight.topOffsetY) < abs(bottomY - estimatedContentHeight.bottomOffsetY) { + estimatedContentHeight.topOffsetY + } else { + estimatedContentHeight.bottomOffsetY - bounds.height + } + setContentOffset(CGPointMake(contentOffset.x, offsetYToStopAt), animated: false) + } + + func isVisible(_ view: UIView) -> Bool { + if view.superview == nil { + return false + } + return view.frame.intersects(CGRectMake(0, contentOffset.y, bounds.width, bounds.height)) + } +} + +private func println(_ text: String) { + print("\(Date.now.timeIntervalSince1970): \(text)") +} diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 66fe67a29e..3154f16f5b 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -78,6 +78,12 @@ struct AddGroupMembersViewCommon: View { let count = selectedContacts.count Section { if creatingGroup { + MemberAdmissionButton( + groupInfo: $groupInfo, + admission: groupInfo.groupProfile.memberAdmission_, + currentAdmission: groupInfo.groupProfile.memberAdmission_, + creatingGroup: true + ) GroupPreferencesButton( groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, @@ -145,9 +151,9 @@ struct AddGroupMembersViewCommon: View { return dummy }() - @ViewBuilder private func inviteMembersButton() -> some View { + private func inviteMembersButton() -> some View { let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat" - Button { + return Button { inviteMembers() } label: { HStack { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index b0f896e493..55d7b626fa 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -17,11 +17,12 @@ struct GroupChatInfoView: View { @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo + @Binding var scrollToItemId: ChatItem.ID? var onSearch: () -> Void @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 @@ -87,7 +88,25 @@ struct GroupChatInfoView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - + + Section { + if groupInfo.canAddMembers && groupInfo.businessChat == nil { + groupLinkButton() + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink(chat: chat, scrollToItemId: $scrollToItemId) + } + if groupInfo.membership.memberActive + && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + } header: { + Text("") + } + Section { if groupInfo.isOwner && groupInfo.businessChat == nil { editGroupButton() @@ -96,19 +115,6 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() - } - - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - } header: { - Text("") } footer: { let label: LocalizedStringKey = ( groupInfo.businessChat == nil @@ -120,6 +126,16 @@ struct GroupChatInfoView: View { } Section { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() + } else { + sendReceiptsOptionDisabled() + } + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) } footer: { Text("Delete chat messages from your device.") @@ -127,9 +143,6 @@ struct GroupChatInfoView: View { Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { if groupInfo.canAddMembers { - if groupInfo.businessChat == nil { - groupLinkButton() - } if (chat.chatInfo.incognito) { Label("Invite members", systemImage: "plus") .foregroundColor(Color(uiColor: .tertiaryLabel)) @@ -138,23 +151,22 @@ struct GroupChatInfoView: View { addMembersButton() } } - if members.count > 8 { - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) - } + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } - MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) + let filteredMembers = s == "" + ? members + : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + MemberRowView( + chat: chat, + groupInfo: groupInfo, + groupMember: GMember(groupInfo.membership), + scrollToItemId: $scrollToItemId, + user: true, + alert: $alert + ) ForEach(filteredMembers) { member in - ZStack { - NavigationLink { - memberInfoView(member) - } label: { - EmptyView() - } - .opacity(0) - MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) - } + MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert) } } @@ -163,7 +175,7 @@ struct GroupChatInfoView: View { if groupInfo.canDelete { deleteGroupButton() } - if groupInfo.membership.memberCurrent { + if groupInfo.membership.memberCurrentOrPending { leaveGroupButton() } } @@ -276,7 +288,9 @@ struct GroupChatInfoView: View { if groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) } - muteButton(width: buttonWidth) + if let nextNtfMode = chat.chatInfo.nextNtfMode { + muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) + } } .frame(maxWidth: .infinity, alignment: .center) } @@ -290,9 +304,9 @@ struct GroupChatInfoView: View { .disabled(!groupInfo.ready || chat.chatItems.isEmpty) } - @ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View { - if chat.chatInfo.incognito { - ZStack { + private func addMembersActionButton(width: CGFloat) -> some View { + ZStack { + if chat.chatInfo.incognito { InfoViewButton(image: "link.badge.plus", title: "invite", width: width) { groupLinkNavLinkActive = true } @@ -304,10 +318,7 @@ struct GroupChatInfoView: View { } .frame(width: 1, height: 1) .hidden() - } - .disabled(!groupInfo.ready) - } else { - ZStack { + } else { InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) { addMembersNavLinkActive = true } @@ -320,17 +331,17 @@ struct GroupChatInfoView: View { .frame(width: 1, height: 1) .hidden() } - .disabled(!groupInfo.ready) } + .disabled(!groupInfo.ready) } - private func muteButton(width: CGFloat) -> some View { - InfoViewButton( - image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill", - title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute", + private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { + return InfoViewButton( + image: nextNtfMode.iconFilled, + title: "\(nextNtfMode.text(mentions: true))", width: width ) { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + toggleNotifications(chat, enableNtfs: nextNtfMode) } .disabled(!groupInfo.ready) } @@ -353,25 +364,23 @@ struct GroupChatInfoView: View { .onAppear { searchFocussed = false Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - chatModel.groupMembers = groupMembers.map { GMember.init($0) } - chatModel.populateGroupMembersIndexes() - } + await chatModel.loadGroupMembers(groupInfo) } } } private struct MemberRowView: View { + var chat: Chat var groupInfo: GroupInfo @ObservedObject var groupMember: GMember + @Binding var scrollToItemId: ChatItem.ID? @EnvironmentObject var theme: AppTheme var user: Bool = false @Binding var alert: GroupChatInfoViewAlert? var body: some View { let member = groupMember.wrapped - let v = HStack{ + let v1 = HStack{ MemberProfileImage(member, size: 38) .padding(.trailing, 2) // TODO server connection status @@ -387,7 +396,21 @@ struct GroupChatInfoView: View { Spacer() memberInfo(member) } - + + let v = ZStack { + if user { + v1 + } else { + NavigationLink { + memberInfoView() + } label: { + EmptyView() + } + .opacity(0) + v1 + } + } + if user { v } else if groupInfo.membership.memberRole >= .admin { @@ -412,6 +435,11 @@ struct GroupChatInfoView: View { } } + private func memberInfoView() -> some View { + GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId) + .navigationBarHidden(false) + } + private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { if member.activeConn?.connDisabled ?? false { return "disabled" @@ -491,11 +519,6 @@ struct GroupChatInfoView: View { } } - private func memberInfoView(_ groupMember: GMember) -> some View { - GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember) - .navigationBarHidden(false) - } - private func groupLinkButton() -> some View { NavigationLink { groupLinkDestinationView() @@ -521,15 +544,95 @@ struct GroupChatInfoView: View { .navigationBarTitleDisplayMode(.large) } + struct UserSupportChatNavLink: View { + @ObservedObject var chat: Chat + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @Binding var scrollToItemId: ChatItem.ID? + @State private var navLinkActive = false + + var body: some View { + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + NavigationLink(isActive: $navLinkActive) { + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) + } label: { + HStack { + Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag") + Spacer() + if chat.supportUnreadCount > 0 { + UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) + } + } + } + .onChange(of: navLinkActive) { active in + if active { + ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + } + } + } + } + + private func memberSupportButton() -> some View { + NavigationLink { + MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + .navigationBarTitle("Chats with members") + .modifier(ThemedBackground()) + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Label( + "Chats with members", + systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag" + ) + Spacer() + if chat.supportUnreadCount > 0 { + UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) + } + } + } + } + + struct GroupReportsChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State private var navLinkActive = false + @ObservedObject var chat: Chat + @Binding var scrollToItemId: ChatItem.ID? + + var body: some View { + NavigationLink(isActive: $navLinkActive) { + SecondaryChatView(chat: chat, scrollToItemId: $scrollToItemId) + } label: { + HStack { + Label { + Text("Member reports") + } icon: { + Image(systemName: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag").foregroundColor(.red) + } + Spacer() + if chat.chatStats.reportsCount > 0 { + UnreadBadge(count: chat.chatStats.reportsCount, color: .red) + } + } + } + .onChange(of: navLinkActive) { active in + if active { + ItemsModel.loadSecondaryChat(chat.id, chatFilter: .msgContentTagContext(contentTag: .report)) + } + } + } + } + private func editGroupButton() -> some View { NavigationLink { GroupProfileView( groupInfo: $groupInfo, groupProfile: groupInfo.groupProfile ) - .navigationBarTitle("Group profile") - .modifier(ThemedBackground()) - .navigationBarTitleDisplayMode(.large) } label: { Label("Edit group profile", systemImage: "pencil") } @@ -571,9 +674,9 @@ struct GroupChatInfoView: View { } } - @ViewBuilder private func leaveGroupButton() -> some View { + private func leaveGroupButton() -> some View { let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" - Button(role: .destructive) { + return Button(role: .destructive) { alert = .leaveGroupAlert } label: { Label(label, systemImage: "rectangle.portrait.and.arrow.right") @@ -681,24 +784,36 @@ struct GroupChatInfoView: View { title: Text("Remove member?"), message: Text(messageLabel), primaryButton: .destructive(Text("Remove")) { - Task { - do { - let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) - await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) - } - } catch let error { - logger.error("apiRemoveMember error: \(responseError(error))") - let a = getErrorAlert(error, "Error removing member") - alert = .error(title: a.title, error: a.message) - } - } + removeMember(groupInfo, mem) }, secondaryButton: .cancel() ) } } +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { + Task { + do { + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + await MainActor.run { + ChatModel.shared.updateGroup(updatedGroupInfo) + updatedMembers.forEach { updatedMember in + _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) + } + dismiss?() + } + } catch let error { + logger.error("apiRemoveMembers error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error removing member", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { groupInfo.businessChat == nil ? ( groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") @@ -792,6 +907,7 @@ struct GroupChatInfoView_Previews: PreviewProvider { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), + scrollToItemId: Binding.constant(nil), onSearch: {}, localAlias: "" ) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 39288e2d52..49e4d49e86 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 @@ -33,16 +35,23 @@ struct GroupLinkView: View { } var body: some View { - if creatingGroup { - groupLinkView() - .navigationBarBackButtonHidden() - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button ("Continue") { linkCreatedCb?() } + ZStack { + if creatingGroup { + groupLinkView() + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } + } } - } - } else { - groupLinkView() + } else { + groupLinkView() + } + if creatingLink { + ProgressView() + .scaleEffect(2) + .frame(maxWidth: .infinity) + } } } @@ -69,14 +78,22 @@ 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") } + if (groupLink.connShortLink == nil && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)) { + Button { + addShortLink() + } label: { + Label("Add short link", systemImage: "plus") + } + } + if !creatingGroup { Button(role: .destructive) { alert = .deleteLink } label: { Label("Delete link", systemImage: "trash") @@ -87,11 +104,10 @@ struct GroupLinkView: View { Label("Create link", systemImage: "link.badge.plus") } .disabled(creatingLink) - if creatingLink { - ProgressView() - .scaleEffect(2) - .frame(maxWidth: .infinity) - } + } + } header: { + if let groupLink, groupLink.connShortLink != nil { + ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink) } } .alert(item: $alert) { alert in @@ -154,12 +170,32 @@ struct GroupLinkView: View { } } } + + private func addShortLink() { + Task { + do { + creatingLink = true + let link = try await apiAddGroupShortLink(groupId) + await MainActor.run { + creatingLink = false + (groupLink, groupLinkMemberRole) = link + } + } catch let error { + logger.error("apiAddGroupShortLink: \(responseError(error))") + await MainActor.run { + creatingLink = false + let a = getErrorAlert(error, "Error adding short link") + alert = .error(title: a.title, error: a.message) + } + } + } + } } 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/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 102f0333be..fa7fc7cae4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -16,6 +16,7 @@ struct GroupMemberInfoView: View { @State var groupInfo: GroupInfo @ObservedObject var chat: Chat @ObservedObject var groupMember: GMember + @Binding var scrollToItemId: ChatItem.ID? var navigation: Bool = false @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @@ -103,6 +104,10 @@ struct GroupMemberInfoView: View { if member.memberActive { Section { + if groupInfo.membership.memberRole >= .moderator + && (member.memberRole < .moderator || member.supportChat != nil) { + MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) + } if let code = connectionCode { verifyCodeButton(code) } if let connStats = connectionStats, connStats.ratchetSyncAllowed { @@ -278,7 +283,7 @@ struct GroupMemberInfoView: View { } } .onChange(of: chat.chatInfo) { c in - if case let .group(gI) = chat.chatInfo { + if case let .group(gI, _) = chat.chatInfo { groupInfo = gI } } @@ -366,14 +371,8 @@ struct GroupMemberInfoView: View { func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View { InfoViewButton(image: "message.fill", title: "message", width: width) { Task { - do { - let chat = try await apiGetChat(type: .direct, id: contactId) - chatModel.addChat(chat) - ItemsModel.shared.loadOpenChat(chat.id) { - dismissAllSheets(animated: true) - } - } catch let error { - logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") + ItemsModel.shared.loadOpenChat("@\(contactId)") { + dismissAllSheets(animated: true) } } } @@ -480,6 +479,31 @@ struct GroupMemberInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + struct MemberInfoSupportChatNavLink: View { + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + var member: GMember + @Binding var scrollToItemId: ChatItem.ID? + @State private var navLinkActive = false + + var body: some View { + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: member.wrapped) + NavigationLink(isActive: $navLinkActive) { + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) + } label: { + Label("Chat with member", systemImage: "flag") + } + .onChange(of: navLinkActive) { active in + if active { + ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + } + } + } + } + private func verifyCodeButton(_ code: String) -> some View { let member = groupMember.wrapped return NavigationLink { @@ -616,13 +640,16 @@ struct GroupMemberInfoView: View { primaryButton: .destructive(Text("Remove")) { Task { do { - let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + chatModel.updateGroup(updatedGroupInfo) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(updatedGroupInfo, updatedMember) + } dismiss() } } catch let error { - logger.error("apiRemoveMember error: \(responseError(error))") + logger.error("apiRemoveMembers error: \(responseError(error))") let a = getErrorAlert(error, "Error removing member") alert = .error(title: a.title, error: a.message) } @@ -647,14 +674,16 @@ struct GroupMemberInfoView: View { primaryButton: .default(Text("Change")) { Task { do { - let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) + let updatedMembers = try await apiMembersRole(groupInfo.groupId, [mem.groupMemberId], newRole) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } } } catch let error { newRole = mem.memberRole - logger.error("apiMemberRole error: \(responseError(error))") + logger.error("apiMembersRole error: \(responseError(error))") let a = getErrorAlert(error, "Error changing role") alert = .error(title: a.title, error: a.message) } @@ -806,12 +835,14 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { Task { do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + let updatedMembers = try await apiBlockMembersForAll(gInfo.groupId, [member.groupMemberId], blocked) await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + } } } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") + logger.error("apiBlockMembersForAll error: \(responseError(error))") } } } @@ -821,7 +852,8 @@ struct GroupMemberInfoView_Previews: PreviewProvider { GroupMemberInfoView( groupInfo: GroupInfo.sampleData, chat: Chat.sampleData, - groupMember: GMember.sampleData + groupMember: GMember.sampleData, + scrollToItemId: Binding.constant(nil) ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift new file mode 100644 index 0000000000..07cc7bd217 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -0,0 +1,269 @@ +// +// GroupMentions.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 30/01/2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +let MENTION_START: Character = "@" +let QUOTE: Character = "'" +let MEMBER_ROW_SIZE: CGFloat = 60 +let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8 + +struct GroupMentionsView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + var im: ItemsModel + var groupInfo: GroupInfo + @Binding var composeState: ComposeState + @Binding var selectedRange: NSRange + @Binding var keyboardVisible: Bool + + @State private var isVisible = false + @State private var currentMessage: String = "" + @State private var mentionName: String = "" + @State private var mentionRange: NSRange? + @State private var mentionMemberId: String? + @State private var sortedMembers: [GMember] = [] + + var body: some View { + ZStack(alignment: .bottom) { + if isVisible { + let filtered = filteredMembers() + if filtered.count > 0 { + Color.white.opacity(0.01) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + isVisible = false + } + VStack(spacing: 0) { + Spacer() + Divider() + let scroll = ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in + let mentioned = mentionMemberId == member.wrapped.memberId + let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned + ZStack(alignment: .bottom) { + memberRowView(member.wrapped, mentioned) + .contentShape(Rectangle()) + .disabled(disabled) + .opacity(disabled ? 0.6 : 1) + .onTapGesture { + memberSelected(member) + } + .padding(.horizontal) + .frame(height: MEMBER_ROW_SIZE) + + Divider() + .padding(.leading) + .padding(.leading, 48) + } + } + } + } + .frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count))) + .background(Color(UIColor.systemBackground)) + + if #available(iOS 16.0, *) { + scroll.scrollDismissesKeyboard(.never) + } else { + scroll + } + } + } + } + } + .onChange(of: composeState.parsedMessage) { parsedMsg in + currentMessage = composeState.message + messageChanged(currentMessage, parsedMsg, selectedRange) + } + .onChange(of: selectedRange) { r in + // This condition is needed to prevent messageChanged called twice, + // because composeState.formattedText triggers later when message changes. + // The condition is only true if position changed without text change + if currentMessage == composeState.message { + messageChanged(currentMessage, composeState.parsedMessage, r) + } + } + .onAppear { + currentMessage = composeState.message + } + } + + func contextMemberFilter(_ member: GroupMember) -> Bool { + switch im.secondaryIMFilter { + case nil: + return true + case let .groupChatScopeContext(groupScopeInfo): + switch (groupScopeInfo) { + case let .memberSupport(groupMember_): + if let scopeMember = groupMember_ { + return member.memberRole >= .moderator || member.groupMemberId == scopeMember.groupMemberId + } else { + return member.memberRole >= .moderator + } + } + case .msgContentTagContext: + return false + } + } + + private func filteredMembers() -> [GMember] { + let s = mentionName.lowercased() + return sortedMembers.filter { + contextMemberFilter($0.wrapped) + && (s.isEmpty || $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s)) + } + } + + private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) { + removeUnusedMentions(parsedMsg) + if let (ft, r) = selectedMarkdown(parsedMsg, range) { + switch ft.format { + case let .mention(name): + isVisible = true + mentionName = name + mentionRange = r + mentionMemberId = composeState.mentions[name]?.memberId + if !m.membersLoaded { + Task { + await m.loadGroupMembers(groupInfo) + sortMembers() + } + } + return + case .none: () // + let pos = range.location + if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" { + let prevChar = getCharacter(msg, pos - 2)?.char + if prevChar == nil || prevChar == " " || prevChar == "\n" { + isVisible = true + mentionName = "" + mentionRange = atRange + mentionMemberId = nil + Task { + await m.loadGroupMembers(groupInfo) + sortMembers() + } + return + } + } + default: () + } + } + closeMemberList() + } + + private func sortMembers() { + sortedMembers = m.groupMembers.filter({ m in + let status = m.wrapped.memberStatus + return status != .memLeft && status != .memRemoved && status != .memInvited + }) + .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } + } + + private func removeUnusedMentions(_ parsedMsg: [FormattedText]) { + let usedMentions: Set = Set(parsedMsg.compactMap { ft in + if case let .mention(name) = ft.format { name } else { nil } + }) + if usedMentions.count < composeState.mentions.count { + composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) })) + } + } + + private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? { + if pos < 0 || pos >= s.count { return nil } + let r = NSRange(location: pos, length: 1) + return if let range = Range(r, in: s) { + (s[range], r) + } else { + nil + } + } + + private func selectedMarkdown(_ parsedMsg: [FormattedText], _ range: NSRange) -> (FormattedText, NSRange)? { + if parsedMsg.isEmpty { return nil } + var i = 0 + var pos: Int = 0 + while i < parsedMsg.count && pos + parsedMsg[i].text.count < range.location { + pos += parsedMsg[i].text.count + i += 1 + } + // the second condition will be true when two markdowns are selected + return i >= parsedMsg.count || range.location + range.length > pos + parsedMsg[i].text.count + ? nil + : (parsedMsg[i], NSRange(location: pos, length: parsedMsg[i].text.count)) + } + + private func memberSelected(_ member: GMember) { + if let range = mentionRange, mentionMemberId == nil || mentionMemberId != member.wrapped.memberId { + addMemberMention(member, range) + } + } + + private func addMemberMention(_ member: GMember, _ r: NSRange) { + guard let range = Range(r, in: composeState.message) else { return } + var mentions = composeState.mentions + var newName: String + if let mm = mentions.first(where: { $0.value.memberId == member.wrapped.memberId }) { + newName = mm.key + } else { + newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName) + } + mentions[newName] = CIMention(groupMember: member.wrapped) + var msgMention = newName.contains(" ") || newName.last?.isPunctuation == true + ? "@'\(newName)'" + : "@\(newName)" + var newPos = r.location + msgMention.count + let newMsgLength = composeState.message.count + msgMention.count - r.length + print(newPos) + print(newMsgLength) + if newPos == newMsgLength { + msgMention += " " + newPos += 1 + } + composeState = composeState.copy( + message: composeState.message.replacingCharacters(in: range, with: msgMention), + mentions: mentions + ) + selectedRange = NSRange(location: newPos, length: 0) + closeMemberList() + keyboardVisible = true + } + + private func closeMemberList() { + isVisible = false + mentionName = "" + mentionRange = nil + mentionMemberId = nil + } + + private func memberRowView(_ member: GroupMember, _ mentioned: Bool) -> some View { + return HStack{ + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + let t = Text(member.localAliasAndFullName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) + (member.verified ? memberVerifiedShield() + t : t) + .lineLimit(1) + } + Spacer() + if mentioned { + Image(systemName: "checkmark") + } + } + + func memberVerifiedShield() -> Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(theme.colors.secondary) + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 9ef53258aa..55b1dc6d2e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -30,6 +30,14 @@ struct GroupPreferencesView: View { let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" VStack { List { + Section { + MemberAdmissionButton( + groupInfo: $groupInfo, + admission: groupInfo.groupProfile.memberAdmission_, + currentAdmission: groupInfo.groupProfile.memberAdmission_, + creatingGroup: creatingGroup + ) + } featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) @@ -37,6 +45,7 @@ struct GroupPreferencesView: View { featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) featureSection(.files, $preferences.files.enable, $preferences.files.role) featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) + featureSection(.reports, $preferences.reports.enable) featureSection(.history, $preferences.history.enable) if groupInfo.isOwner { @@ -89,6 +98,7 @@ struct GroupPreferencesView: View { settingsRow(icon, color: color) { Toggle(feature.text, isOn: enable) } + .disabled(feature == .reports) // remove in 6.4 if timedOn { DropdownCustomTimePicker( selection: $preferences.timedMessages.ttl, @@ -138,6 +148,66 @@ struct GroupPreferencesView: View { } } +struct MemberAdmissionButton: View { + @Binding var groupInfo: GroupInfo + @State var admission: GroupMemberAdmission + @State var currentAdmission: GroupMemberAdmission + var creatingGroup: Bool = false + + var body: some View { + NavigationLink { + MemberAdmissionView( + groupInfo: $groupInfo, + admission: $admission, + currentAdmission: currentAdmission, + creatingGroup: creatingGroup, + saveAdmission: saveAdmission + ) + .navigationBarTitle("Member admission") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + .onDisappear { + let saveText = NSLocalizedString( + creatingGroup ? "Save" : "Save and notify group members", + comment: "alert button" + ) + + if groupInfo.groupProfile.memberAdmission_ != admission { + showAlert( + title: NSLocalizedString("Save admission settings?", comment: "alert title"), + buttonTitle: saveText, + buttonAction: { saveAdmission() }, + cancelButton: true + ) + } + } + } label: { + if creatingGroup { + Text("Set member admission") + } else { + Label("Member admission", systemImage: "switch.2") + } + } + } + + private func saveAdmission() { + Task { + do { + var gp = groupInfo.groupProfile + gp.memberAdmission = admission + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + currentAdmission = admission + } + } catch { + logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))") + } + } + } +} + struct GroupPreferencesView_Previews: PreviewProvider { static var previews: some View { GroupPreferencesView( diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 1617edd11f..4de3608735 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -26,6 +26,7 @@ struct GroupProfileView: View { @Environment(\.dismiss) var dismiss: DismissAction @Binding var groupInfo: GroupInfo @State var groupProfile: GroupProfile + @State private var currentProfileHash: Int? @State private var showChooseSource = false @State private var showImagePicker = false @State private var showTakePhoto = false @@ -34,60 +35,40 @@ struct GroupProfileView: View { @FocusState private var focusDisplayName var body: some View { - return VStack(alignment: .leading) { - Text("Group profile is stored on members' devices, not on the servers.") - .padding(.vertical) + List { + EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource) + .if(!focusDisplayName) { $0.padding(.top) } - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - profileImageView(groupProfile.image) - if groupProfile.image != nil { - Button { - groupProfile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) - } - } - } - - editImageButton { showChooseSource = true } - } - .frame(maxWidth: .infinity, alignment: .center) - - VStack(alignment: .leading) { - ZStack(alignment: .topLeading) { - if !validNewProfileName() { + Section { + HStack { + TextField("Group display name", text: $groupProfile.displayName) + .focused($focusDisplayName) + if !validNewProfileName { Button { alert = .invalidName(validName: mkValidName(groupProfile.displayName)) } label: { Image(systemName: "exclamationmark.circle").foregroundColor(.red) } - } else { - Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - profileNameTextEdit("Group display name", $groupProfile.displayName) - .focused($focusDisplayName) } - .padding(.bottom) let fullName = groupInfo.groupProfile.fullName if fullName != "" && fullName != groupProfile.displayName { - profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) - .padding(.bottom) - } - HStack(spacing: 20) { - Button("Cancel") { dismiss() } - Button("Save group profile") { saveProfile() } - .disabled(!canUpdateProfile()) + TextField("Group full name (optional)", text: $groupProfile.fullName) } + } footer: { + Text("Group profile is stored on members' devices, not on the servers.") } - .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) + Section { + Button("Reset") { + groupProfile = groupInfo.groupProfile + currentProfileHash = groupProfile.hashValue + } + .disabled(currentProfileHash == groupProfile.hashValue) + Button("Save group profile", action: saveProfile) + .disabled(!canUpdateProfile) + } } - .padding() - .frame(maxHeight: .infinity, alignment: .top) .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true @@ -95,6 +76,11 @@ struct GroupProfileView: View { Button("Choose from library") { showImagePicker = true } + if UIPasteboard.general.hasImages { + Button("Paste image") { + chosenImage = UIPasteboard.general.image + } + } } .fullScreenCover(isPresented: $showTakePhoto) { ZStack { @@ -120,8 +106,20 @@ struct GroupProfileView: View { } } .onAppear { + currentProfileHash = groupProfile.hashValue DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - focusDisplayName = true + withAnimation { focusDisplayName = true } + } + } + .onDisappear { + if canUpdateProfile { + showAlert( + title: NSLocalizedString("Save group profile?", comment: "alert title"), + message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"), + buttonAction: saveProfile, + cancelButton: true + ) } } .alert(item: $alert) { a in @@ -135,30 +133,30 @@ struct GroupProfileView: View { return createInvalidNameAlert(name, $groupProfile.displayName) } } - .contentShape(Rectangle()) - .onTapGesture { hideKeyboard() } + .navigationBarTitle("Group profile") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large) } - private func canUpdateProfile() -> Bool { - groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName() + private var canUpdateProfile: Bool { + currentProfileHash != groupProfile.hashValue && + groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && + validNewProfileName } - private func validNewProfileName() -> Bool { + private var validNewProfileName: Bool { groupProfile.displayName == groupInfo.groupProfile.displayName || validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces)) } - func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View { - TextField(label, text: name) - .padding(.leading, 32) - } - func saveProfile() { Task { do { groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces) + groupProfile.fullName = groupProfile.fullName.trimmingCharacters(in: .whitespaces) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) await MainActor.run { + currentProfileHash = groupProfile.hashValue groupInfo = gInfo chatModel.updateGroup(gInfo) dismiss() diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index 8dfc32f6ea..97bff70efb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -18,6 +18,7 @@ struct GroupWelcomeView: View { @State private var editMode = true @FocusState private var keyboardVisible: Bool @State private var showSaveDialog = false + @State private var showSecrets: Set = [] let maxByteCount = 1200 @@ -58,7 +59,8 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary) + let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background)) + return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift new file mode 100644 index 0000000000..d80615b5d2 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift @@ -0,0 +1,93 @@ +// +// MemberAdmissionView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let memberCriterias: [(criteria: MemberCriteria?, text: LocalizedStringKey)] = [ + (nil, "off"), + (.all, "all") +] + +struct MemberAdmissionView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @Binding var groupInfo: GroupInfo + @Binding var admission: GroupMemberAdmission + var currentAdmission: GroupMemberAdmission + let creatingGroup: Bool + let saveAdmission: () -> Void + @State private var showSaveDialogue = false + + var body: some View { + let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" + VStack { + List { + admissionSection( + NSLocalizedString("Review members", comment: "admission stage"), + NSLocalizedString("Review members before admitting (\"knocking\").", comment: "admission stage description"), + $admission.review + ) + + if groupInfo.isOwner { + Section { + Button("Reset") { admission = currentAdmission } + Button(saveText) { saveAdmission() } + } + .disabled(currentAdmission == admission) + } + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + if currentAdmission == admission { + dismiss() + } else { + showSaveDialogue = true + } + }) + .confirmationDialog("Save admission settings?", isPresented: $showSaveDialogue) { + Button(saveText) { + saveAdmission() + dismiss() + } + Button("Exit without saving") { + admission = currentAdmission + dismiss() + } + } + } + + private func admissionSection(_ admissionStageStr: String, _ admissionStageDescrStr: String, _ memberCriteria: Binding) -> some View { + Section { + if groupInfo.isOwner { + Picker(admissionStageStr, selection: memberCriteria) { + ForEach(memberCriterias, id: \.criteria) { mc in + Text(mc.text) + } + } + .frame(height: 36) + } else { + infoRow(Text(admissionStageStr), memberCriteria.wrappedValue?.text ?? NSLocalizedString("off", comment: "member criteria value")) + } + } footer: { + Text(admissionStageDescrStr) + .foregroundColor(theme.colors.secondary) + } + } +} + +#Preview { + MemberAdmissionView( + groupInfo: Binding.constant(GroupInfo.sampleData), + admission: Binding.constant(GroupMemberAdmission.sampleData), + currentAdmission: GroupMemberAdmission.sampleData, + creatingGroup: false, + saveAdmission: {} + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift new file mode 100644 index 0000000000..23001e64bf --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -0,0 +1,44 @@ +// +// MemberSupportChatToolbar.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 01.05.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MemberSupportChatToolbar: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + var groupMember: GroupMember + var imageSize: CGFloat = 32 + + var body: some View { + return HStack { + MemberProfileImage(groupMember, size: imageSize) + .padding(.trailing, 4) + let t = Text(groupMember.chatViewName).font(.headline) + (groupMember.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + } + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) + } + + private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .baselineOffset(1) + .kerning(-2) + } +} + +#Preview { + MemberSupportChatToolbar( + groupMember: GroupMember.sampleData + ) + .environmentObject(CurrentColors.toAppTheme()) +} diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift new file mode 100644 index 0000000000..7f3672ea17 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -0,0 +1,287 @@ +// +// MemberSupportView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MemberSupportView: View { + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State private var searchText: String = "" + @FocusState private var searchFocussed + var groupInfo: GroupInfo + @Binding var scrollToItemId: ChatItem.ID? + + var body: some View { + viewBody() + .onAppear { + Task { + await chatModel.loadGroupMembers(groupInfo) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { + await chatModel.loadGroupMembers(groupInfo) + } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + } + + @ViewBuilder private func viewBody() -> some View { + let membersWithChats = sortedMembersWithChats() + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembersWithChats = s == "" + ? membersWithChats + : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + + if membersWithChats.isEmpty { + Text("No chats with members") + .foregroundColor(.secondary) + } else { + List { + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + ForEach(filteredMembersWithChats) { memberWithChat in + MemberSupportChatNavLink( + groupInfo: groupInfo, + memberWithChat: memberWithChat, + scrollToItemId: $scrollToItemId + ) + } + } + } + } + + struct MemberSupportChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State private var memberSupportChatNavLinkActive = false + var groupInfo: GroupInfo + var memberWithChat: GMember + @Binding var scrollToItemId: ChatItem.ID? + + var body: some View { + ZStack { + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped) + Button { + ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) { + memberSupportChatNavLinkActive = true + } + } label: { + SupportChatRowView(groupMember: memberWithChat, groupInfo: groupInfo) + } + + NavigationLink(isActive: $memberSupportChatNavLinkActive) { + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if memberWithChat.wrapped.memberPending { + Button { + showAcceptMemberAlert(groupInfo, memberWithChat.wrapped) + } label: { + Label("Accept", systemImage: "checkmark") + } + .tint(theme.colors.primary) + } else { + Button { + showDeleteMemberSupportChatAlert(groupInfo, memberWithChat.wrapped) + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + } + } + } + + func sortedMembersWithChats() -> [GMember] { + chatModel.groupMembers + .filter { + $0.wrapped.supportChat != nil && + $0.wrapped.memberStatus != .memLeft && + $0.wrapped.memberStatus != .memRemoved + } + .sorted { (m0: GMember, m1: GMember) -> Bool in + if m0.wrapped.memberPending != m1.wrapped.memberPending { + return m0.wrapped.memberPending + } + + let mentions0 = (m0.wrapped.supportChat?.mentions ?? 0) > 0 + let mentions1 = (m1.wrapped.supportChat?.mentions ?? 0) > 0 + if mentions0 != mentions1 { + return mentions0 + } + + let attention0 = (m0.wrapped.supportChat?.memberAttention ?? 0) > 0 + let attention1 = (m1.wrapped.supportChat?.memberAttention ?? 0) > 0 + if attention0 != attention1 { + return attention0 + } + + let unread0 = (m0.wrapped.supportChat?.unread ?? 0) > 0 + let unread1 = (m1.wrapped.supportChat?.unread ?? 0) > 0 + if unread0 != unread1 { + return unread0 + } + + return (m0.wrapped.supportChat?.chatTs ?? .distantPast) > (m1.wrapped.supportChat?.chatTs ?? .distantPast) + } + } + + private struct SupportChatRowView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject var groupMember: GMember + @EnvironmentObject var theme: AppTheme + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var groupInfo: GroupInfo + + var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize } + + var body: some View { + let member = groupMember.wrapped + HStack{ + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground) + (member.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + Text(memberStatus(member)) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + + Spacer() + + if member.memberPending { + Image(systemName: "flag.fill") + .resizable() + .scaledToFill() + .frame(width: dynamicChatInfoSize * 0.8, height: dynamicChatInfoSize * 0.8) + .foregroundColor(theme.colors.primary) + } + if let supportChat = member.supportChat { + SupportChatUnreadIndicator(supportChat: supportChat) + } + } + } + + private func memberStatus(_ member: GroupMember) -> LocalizedStringKey { + if member.activeConn?.connDisabled ?? false { + return "disabled" + } else if member.activeConn?.connInactive ?? false { + return "inactive" + } else if member.memberPending { + return member.memberStatus.text + } else { + return LocalizedStringKey(member.memberRole.text) + } + } + + struct SupportChatUnreadIndicator: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var supportChat: GroupSupportChat + + var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize } + + private var indicatorTint: Color { + if supportChat.mentions > 0 || supportChat.memberAttention > 0 { + return theme.colors.primary + } else { + return theme.colors.secondary + } + } + + var body: some View { + HStack(alignment: .center, spacing: 2) { + if supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0 { + if supportChat.mentions > 0 && supportChat.unread > 1 { + Text("\(MENTION_START)") + .font(userFont <= .xxxLarge ? .body : .callout) + .foregroundColor(indicatorTint) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .cornerRadius(dynamicSize(userFont).unreadCorner) + .padding(.bottom, 1) + } + let singleUnreadIsMention = supportChat.mentions > 0 && supportChat.unread == 1 + (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(supportChat.unread)) + .font(userFont <= .xxxLarge ? .caption : .caption2) + .foregroundColor(.white) + .padding(.horizontal, dynamicSize(userFont).unreadPadding) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .background(indicatorTint) + .cornerRadius(dynamicSize(userFont).unreadCorner) + } + } + .frame(height: dynamicChatInfoSize) + .frame(minWidth: 22) + } + } + + private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(theme.colors.secondary) + } + } +} + +func showDeleteMemberSupportChatAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { + showAlert( + title: NSLocalizedString("Delete chat with member?", comment: "alert title"), + buttonTitle: "Delete", + buttonAction: { deleteMemberSupportChat(groupInfo, member) }, + cancelButton: true + ) +} + +func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) { + Task { + do { + let (gInfo, updatedMember) = try await apiDeleteMemberSupportChat(groupInfo.groupId, member.groupMemberId) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + ChatModel.shared.updateGroup(gInfo) + } + // TODO member row doesn't get removed from list (upsertGroupMember correctly sets supportChat to nil) - this repopulates list to fix it + await ChatModel.shared.loadGroupMembers(gInfo) + } catch let error { + logger.error("apiDeleteMemberSupportChat error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error deleting chat with member", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + +#Preview { + MemberSupportView( + groupInfo: GroupInfo.sampleData, + scrollToItemId: Binding.constant(nil) + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift new file mode 100644 index 0000000000..47c5df264f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -0,0 +1,42 @@ +// +// SecondaryChatView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SecondaryChatView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject var chat: Chat + @Binding var scrollToItemId: ChatItem.ID? + + var body: some View { + if let im = chatModel.secondaryIM { + ChatView( + chat: chat, + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im), + scrollToItemId: $scrollToItemId + ) + .onDisappear { + chatModel.secondaryIM = nil + } + } + } +} + +#Preview { + SecondaryChatView( + chat: Chat( + chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), + chatItems: [], + chatStats: ChatStats() + ), + scrollToItemId: Binding.constant(nil) + ) +} diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift deleted file mode 100644 index e33adcef58..0000000000 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ /dev/null @@ -1,371 +0,0 @@ -// -// ReverseList.swift -// SimpleX (iOS) -// -// Created by Levitating Pineapple on 11/06/2024. -// Copyright © 2024 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import Combine -import SimpleXChat - -/// A List, which displays it's items in reverse order - from bottom to top -struct ReverseList: UIViewControllerRepresentable { - let items: Array - - @Binding var scrollState: ReverseListScrollModel.State - - /// Closure, that returns user interface for a given item - let content: (ChatItem) -> Content - - let loadPage: () -> Void - - func makeUIViewController(context: Context) -> Controller { - Controller(representer: self) - } - - func updateUIViewController(_ controller: Controller, context: Context) { - controller.representer = self - if case let .scrollingTo(destination) = scrollState, !items.isEmpty { - controller.view.layer.removeAllAnimations() - switch destination { - case .nextPage: - controller.scrollToNextPage() - case let .item(id): - controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) - case .bottom: - controller.scroll(to: 0, position: .top) - } - } else { - controller.update(items: items) - } - } - - /// Controller, which hosts SwiftUI cells - class Controller: UITableViewController { - private enum Section { case main } - var representer: ReverseList - private var dataSource: UITableViewDiffableDataSource! - private var itemCount: Int = 0 - private let updateFloatingButtons = PassthroughSubject() - private var bag = Set() - - init(representer: ReverseList) { - self.representer = representer - super.init(style: .plain) - - // 1. Style - tableView = InvertedTableView() - tableView.separatorStyle = .none - tableView.transform = .verticalFlip - tableView.backgroundColor = .clear - - // 2. Register cells - if #available(iOS 16.0, *) { - tableView.register( - UITableViewCell.self, - forCellReuseIdentifier: cellReuseId - ) - } else { - tableView.register( - HostingCell.self, - forCellReuseIdentifier: cellReuseId - ) - } - - // 3. Configure data source - self.dataSource = UITableViewDiffableDataSource( - tableView: tableView - ) { (tableView, indexPath, item) -> UITableViewCell? in - if indexPath.item > self.itemCount - 8 { - self.representer.loadPage() - } - let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) - if #available(iOS 16.0, *) { - cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) } - .margins(.all, 0) - .minSize(height: 1) // Passing zero will result in system default of 44 points being used - } else { - if let cell = cell as? HostingCell { - cell.set(content: self.representer.content(item), parent: self) - } else { - fatalError("Unexpected Cell Type for: \(item)") - } - } - cell.transform = .verticalFlip - cell.selectionStyle = .none - cell.backgroundColor = .clear - return cell - } - - // 4. External state changes will require manual layout updates - NotificationCenter.default - .addObserver( - self, - selector: #selector(updateLayout), - name: notificationName, - object: nil - ) - - updateFloatingButtons - .throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true) - .sink { - if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) { - ChatView.FloatingButtonModel.shared.updateOnListChange(listState) - } - } - .store(in: &bag) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - deinit { NotificationCenter.default.removeObserver(self) } - - @objc private func updateLayout() { - if #available(iOS 16.0, *) { - tableView.setNeedsLayout() - tableView.layoutIfNeeded() - } else { - tableView.reloadData() - } - } - - /// Hides keyboard, when user begins to scroll. - /// Equivalent to `.scrollDismissesKeyboard(.immediately)` - override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - UIApplication.shared - .sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil) - } - - override func viewDidAppear(_ animated: Bool) { - tableView.clipsToBounds = false - parent?.viewIfLoaded?.clipsToBounds = false - } - - /// Scrolls up - func scrollToNextPage() { - tableView.setContentOffset( - CGPoint( - x: tableView.contentOffset.x, - y: tableView.contentOffset.y + tableView.bounds.height - ), - animated: true - ) - Task { representer.scrollState = .atDestination } - } - - /// Scrolls to Item at index path - /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil` - func scroll(to index: Int?, position: UITableView.ScrollPosition) { - var animated = false - if #available(iOS 16.0, *) { - animated = true - } - if let index, tableView.numberOfRows(inSection: 0) != 0 { - tableView.scrollToRow( - at: IndexPath(row: index, section: 0), - at: position, - animated: animated - ) - } else { - tableView.setContentOffset( - CGPoint(x: .zero, y: -InvertedTableView.inset), - animated: animated - ) - } - Task { representer.scrollState = .atDestination } - } - - func update(items: [ChatItem]) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(items) - dataSource.defaultRowAnimation = .none - dataSource.apply( - snapshot, - animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1 - ) - // Sets content offset on initial load - if itemCount == 0 { - tableView.setContentOffset( - CGPoint(x: 0, y: -InvertedTableView.inset), - animated: false - ) - } - itemCount = items.count - updateFloatingButtons.send() - } - - override func scrollViewDidScroll(_ scrollView: UIScrollView) { - updateFloatingButtons.send() - } - - func getListState() -> ListState? { - if let visibleRows = tableView.indexPathsForVisibleRows, - visibleRows.last?.item ?? 0 < representer.items.count { - let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset - let topItemDate: Date? = - if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) { - representer.items[lastVisible.item].meta.itemTs - } else { - nil - } - let bottomItemId: ChatItem.ID? = - if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) { - representer.items[firstVisible.item].id - } else { - nil - } - return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId) - } - return nil - } - - private func isVisible(indexPath: IndexPath) -> Bool { - if let relativeFrame = tableView.superview?.convert( - tableView.rectForRow(at: indexPath), - from: tableView - ) { - relativeFrame.maxY > InvertedTableView.inset && - relativeFrame.minY < tableView.frame.height - InvertedTableView.inset - } else { false } - } - } - - /// `UIHostingConfiguration` back-port for iOS14 and iOS15 - /// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController` - private final class HostingCell: UITableViewCell { - private let hostingController = UIHostingController(rootView: nil) - - /// Updates content of the cell - /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/ - func set(content: Hosted, parent: UIViewController) { - hostingController.view.backgroundColor = .clear - hostingController.rootView = content - if let hostingView = hostingController.view { - hostingView.invalidateIntrinsicContentSize() - if hostingController.parent != parent { parent.addChild(hostingController) } - if !contentView.subviews.contains(hostingController.view) { - contentView.addSubview(hostingController.view) - hostingView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingView.leadingAnchor - .constraint(equalTo: contentView.leadingAnchor), - hostingView.trailingAnchor - .constraint(equalTo: contentView.trailingAnchor), - hostingView.topAnchor - .constraint(equalTo: contentView.topAnchor), - hostingView.bottomAnchor - .constraint(equalTo: contentView.bottomAnchor) - ]) - } - if hostingController.parent != parent { hostingController.didMove(toParent: parent) } - } else { - fatalError("Hosting View not loaded \(hostingController)") - } - } - - override func prepareForReuse() { - super.prepareForReuse() - hostingController.rootView = nil - } - } -} - -typealias ListState = ( - scrollOffset: Double, - topItemDate: Date?, - bottomItemId: ChatItem.ID? -) - -/// Manages ``ReverseList`` scrolling -class ReverseListScrollModel: ObservableObject { - /// Represents Scroll State of ``ReverseList`` - enum State: Equatable { - enum Destination: Equatable { - case nextPage - case item(ChatItem.ID) - case bottom - } - - case scrollingTo(Destination) - case atDestination - } - - @Published var state: State = .atDestination - - func scrollToNextPage() { - state = .scrollingTo(.nextPage) - } - - func scrollToBottom() { - state = .scrollingTo(.bottom) - } - - func scrollToItem(id: ChatItem.ID) { - state = .scrollingTo(.item(id)) - } -} - -fileprivate let cellReuseId = "hostingCell" - -fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout") - -fileprivate extension CGAffineTransform { - /// Transform that vertically flips the view, preserving it's location - static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1) -} - -extension NotificationCenter { - static func postReverseListNeedsLayout() { - NotificationCenter.default.post( - name: notificationName, - object: nil - ) - } -} - -/// Disable animation on iOS 15 -func withConditionalAnimation( - _ animation: Animation? = .default, - _ body: () throws -> Result -) rethrows -> Result { - if #available(iOS 16.0, *) { - try withAnimation(animation, body) - } else { - try body() - } -} - -class InvertedTableView: UITableView { - static let inset = CGFloat(100) - - static let insets = UIEdgeInsets( - top: inset, - left: .zero, - bottom: inset, - right: .zero - ) - - override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior { - get { .never } - set { } - } - - override var contentInset: UIEdgeInsets { - get { Self.insets } - set { } - } - - override var adjustedContentInset: UIEdgeInsets { - Self.insets - } -} diff --git a/apps/ios/Shared/Views/Chat/ScrollViewCells.swift b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift new file mode 100644 index 0000000000..d062627d5b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift @@ -0,0 +1,52 @@ +// +// ScrollViewCells.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 27.01.2025. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +protocol ReusableView { + func prepareForReuse() +} + +/// `UIHostingConfiguration` back-port for iOS14 and iOS15 +/// Implemented as a `UIView` that wraps and manages a generic `UIHostingController` +final class HostingCell: UIView, ReusableView { + private let hostingController = UIHostingController(rootView: nil) + + /// Updates content of the cell + /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/ + func set(content: Hosted, parent: UIViewController) { + hostingController.view.backgroundColor = .clear + hostingController.rootView = content + if let hostingView = hostingController.view { + hostingView.invalidateIntrinsicContentSize() + if hostingController.parent != parent { parent.addChild(hostingController) } + if !subviews.contains(hostingController.view) { + addSubview(hostingController.view) + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.leadingAnchor + .constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor + .constraint(equalTo: trailingAnchor), + hostingView.topAnchor + .constraint(equalTo: topAnchor), + hostingView.bottomAnchor + .constraint(equalTo: bottomAnchor) + ]) + } + if hostingController.parent != parent { hostingController.didMove(toParent: parent) } + } else { + fatalError("Hosting View not loaded \(hostingController)") + } + } + + func prepareForReuse() { + //super.prepareForReuse() + hostingController.rootView = nil + } +} diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 81498ee497..e397970acd 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -25,17 +25,20 @@ struct SelectedItemsTopToolbar: View { struct SelectedItemsBottomToolbar: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme - let chatItems: [ChatItem] + let im: ItemsModel @Binding var selectedChatItems: Set? var chatInfo: ChatInfo // Bool - delete for everyone is possible var deleteItems: (Bool) -> Void + var archiveItems: () -> Void var moderateItems: () -> Void //var shareItems: () -> Void var forwardItems: () -> Void @State var deleteEnabled: Bool = false @State var deleteForEveryoneEnabled: Bool = false + @State var canArchiveReports: Bool = false + @State var canModerate: Bool = false @State var moderateEnabled: Bool = false @@ -50,7 +53,11 @@ struct SelectedItemsBottomToolbar: View { HStack(alignment: .center) { Button { - deleteItems(deleteForEveryoneEnabled) + if canArchiveReports { + archiveItems() + } else { + deleteItems(deleteForEveryoneEnabled) + } } label: { Image(systemName: "trash") .resizable() @@ -68,9 +75,9 @@ struct SelectedItemsBottomToolbar: View { .resizable() .scaledToFit() .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red) + .foregroundColor(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : .red) } - .disabled(!moderateEnabled || deleteCountProhibited) + .disabled(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil) .opacity(canModerate ? 1 : 0) Spacer() @@ -81,24 +88,24 @@ struct SelectedItemsBottomToolbar: View { .resizable() .scaledToFit() .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary) + .foregroundColor(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : theme.colors.primary) } - .disabled(!forwardEnabled || forwardCountProhibited) + .disabled(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil) } .frame(maxHeight: .infinity) .padding([.leading, .trailing], 12) } .onAppear { - recheckItems(chatInfo, chatItems, selectedChatItems) + recheckItems(chatInfo, im.reversedChatItems, selectedChatItems) } .onChange(of: chatInfo) { info in - recheckItems(info, chatItems, selectedChatItems) + recheckItems(info, im.reversedChatItems, selectedChatItems) } - .onChange(of: chatItems) { items in + .onChange(of: im.reversedChatItems) { items in recheckItems(chatInfo, items, selectedChatItems) } .onChange(of: selectedChatItems) { selected in - recheckItems(chatInfo, chatItems, selected) + recheckItems(chatInfo, im.reversedChatItems, selected) } .frame(height: 55.5) .background(.thinMaterial) @@ -109,19 +116,25 @@ struct SelectedItemsBottomToolbar: View { deleteCountProhibited = count == 0 || count > 200 forwardCountProhibited = count == 0 || count > 20 canModerate = possibleToModerate(chatInfo) + let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info, _) = chatInfo { + info + } else { + nil + } if let selected = selectedItems { let me: Bool let onlyOwnGroupItems: Bool - (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in + (deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in if selected.contains(ci.id) { - var (de, dee, me, onlyOwnGroupItems, fe, sel) = r + var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r de = de && ci.canBeDeletedForSelf dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport + ar = ar && ci.isActiveReport && ci.chatDir != .groupSnd && groupInfo != nil && groupInfo!.membership.memberRole >= .moderator onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list - return (de, dee, me, onlyOwnGroupItems, fe, sel) + return (de, dee, ar, me, onlyOwnGroupItems, fe, sel) } else { return r } @@ -132,7 +145,7 @@ struct SelectedItemsBottomToolbar: View { private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool { return switch chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): groupInfo.membership.memberRole >= .admin default: false } diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift index 7b01fe0300..373311073a 100644 --- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift +++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift @@ -24,85 +24,70 @@ struct VerifyCodeView: View { } private func verifyCodeView(_ code: String) -> some View { - ScrollView { - let splitCode = splitToParts(code, length: 24) - VStack(alignment: .leading) { - Group { + let splitCode = splitToParts(code, length: 24) + return List { + Section { + QRCode(uri: code, small: true) + + Text(splitCode) + .multilineTextAlignment(.leading) + .font(.body.monospaced()) + .lineLimit(20) + .frame(maxWidth: .infinity, alignment: .center) + } header: { + if connectionVerified { HStack { - if connectionVerified { - Image(systemName: "checkmark.shield") - .foregroundColor(theme.colors.secondary) - Text("\(displayName) is verified") - } else { - Text("\(displayName) is not verified") - } + Image(systemName: "checkmark.shield").foregroundColor(theme.colors.secondary) + Text("\(displayName) is verified").textCase(.none) } - .frame(height: 24) - - QRCode(uri: code) - .padding(.horizontal) - - Text(splitCode) - .multilineTextAlignment(.leading) - .font(.body.monospaced()) - .lineLimit(20) - .padding(.bottom, 8) + } else { + Text("\(displayName) is not verified").textCase(.none) } - .frame(maxWidth: .infinity, alignment: .center) - + } footer: { Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.") - .padding(.bottom) + } - Group { - if connectionVerified { - Button { - verifyCode(nil) - } label: { - Label("Clear verification", systemImage: "shield") - } - .padding() - } else { - HStack { - NavigationLink { - ScanCodeView(connectionVerified: $connectionVerified, verify: verify) - .navigationBarTitleDisplayMode(.large) - .navigationTitle("Scan code") - .modifier(ThemedBackground()) - } label: { - Label("Scan code", systemImage: "qrcode") - } - .padding() - Button { - verifyCode(code) { verified in - if !verified { showCodeError = true } - } - } label: { - Label("Mark verified", systemImage: "checkmark.shield") - } - .padding() - .alert(isPresented: $showCodeError) { - Alert(title: Text("Incorrect security code!")) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .center) - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + Section { + if connectionVerified { Button { - showShareSheet(items: [splitCode]) + verifyCode(nil) } label: { - Image(systemName: "square.and.arrow.up") + Label("Clear verification", systemImage: "shield") + } + } else { + NavigationLink { + ScanCodeView(connectionVerified: $connectionVerified, verify: verify) + .navigationBarTitleDisplayMode(.large) + .navigationTitle("Scan code") + .modifier(ThemedBackground()) + } label: { + Label("Scan code", systemImage: "qrcode") + } + Button { + verifyCode(code) { verified in + if !verified { showCodeError = true } + } + } label: { + Label("Mark verified", systemImage: "checkmark.shield") + } + .alert(isPresented: $showCodeError) { + Alert(title: Text("Incorrect security code!")) } } } - .onChange(of: connectionVerified) { _ in - if connectionVerified { dismiss() } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showShareSheet(items: [splitCode]) + } label: { + Image(systemName: "square.and.arrow.up") + } } } + .onChange(of: connectionVerified) { _ in + if connectionVerified { dismiss() } + } } private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f1ee4e4c42..1e747b8019 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -66,7 +66,7 @@ struct ChatListNavLink: View { switch chat.chatInfo { case let .direct(contact): contactNavLink(contact) - case let .group(groupInfo): + case let .group(groupInfo, _): groupNavLink(groupInfo) case let .local(noteFolder): noteFolderNavLink(noteFolder) @@ -90,11 +90,11 @@ struct ChatListNavLink: View { .actionSheet(item: $actionSheet) { $0.actionSheet } } - @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { + private func contactNavLink(_ contact: Contact) -> some View { Group { if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { deleteContactDialog( @@ -121,6 +121,7 @@ struct ChatListNavLink: View { selection: $chatModel.chatId, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } ) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() @@ -145,7 +146,6 @@ struct ChatListNavLink: View { } .tint(.red) } - .frame(height: dynamicRowHeight) } } .alert(item: $alert) { $0.alert } @@ -163,7 +163,7 @@ struct ChatListNavLink: View { switch (groupInfo.membership.memberStatus) { case .memInvited: ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { joinGroupButton() if groupInfo.canDelete { @@ -183,13 +183,13 @@ struct ChatListNavLink: View { .disabled(inProgress) case .memAccepted: ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) } .swipeActions(edge: .trailing) { tagChatButton(chat) - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { leaveGroupChatButton(groupInfo) } if groupInfo.canDelete { @@ -203,7 +203,7 @@ struct ChatListNavLink: View { label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !groupInfo.ready ) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() @@ -211,37 +211,46 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { tagChatButton(chat) + let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator let showClearButton = !chat.chatItems.isEmpty let showDeleteGroup = groupInfo.canDelete - let showLeaveGroup = groupInfo.membership.memberCurrent - let totalNumberOfButtons = 1 + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) + let showLeaveGroup = groupInfo.membership.memberCurrentOrPending + let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) - if showClearButton, totalNumberOfButtons <= 3 { + if showClearButton && totalNumberOfButtons <= 3 { clearChatButton() } - if (showLeaveGroup) { + + if showReportsButton && totalNumberOfButtons <= 3 { + archiveAllReportsButton() + } + + if showLeaveGroup { leaveGroupChatButton(groupInfo) } - - if showDeleteGroup { - if totalNumberOfButtons <= 3 { + + if showDeleteGroup && totalNumberOfButtons <= 3 { + deleteGroupChatButton(groupInfo) + } else if totalNumberOfButtons > 3 { + if showDeleteGroup && !groupInfo.membership.memberActive { deleteGroupChatButton(groupInfo) + moreOptionsButton(false, chat, groupInfo) } else { - moreOptionsButton(chat, groupInfo) + moreOptionsButton(true, chat, groupInfo) } } } } } - @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { + private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { NavLinkPlain( chatId: chat.chatInfo.id, selection: $chatModel.chatId, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !noteFolder.ready ) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() } @@ -267,7 +276,7 @@ struct ChatListNavLink: View { @ViewBuilder private func markReadButton() -> some View { if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat { Button { - Task { await markChatRead(chat) } + Task { await markChatRead(ItemsModel.shared, chat) } } label: { SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } @@ -302,14 +311,22 @@ struct ChatListNavLink: View { } @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { - Button { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) - } label: { - if chat.chatInfo.ntfsEnabled { - SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI) - } else { - SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI) + if let nextMode = chat.chatInfo.nextNtfMode { + Button { + toggleNotifications(chat, enableNtfs: nextMode) + } label: { + SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI) } + } else { + EmptyView() + } + } + + private func archiveAllReportsButton() -> some View { + Button { + AlertManager.shared.showAlert(archiveAllReportsAlert()) + } label: { + SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI) } } @@ -354,15 +371,20 @@ struct ChatListNavLink: View { ) } - private func moreOptionsButton(_ chat: Chat, _ groupInfo: GroupInfo?) -> some View { + private func moreOptionsButton(_ canShowGroupDelete: Bool, _ chat: Chat, _ groupInfo: GroupInfo?) -> some View { Button { - var buttons: [Alert.Button] = [ - .default(Text("Clear")) { - AlertManager.shared.showAlert(clearChatAlert()) - } - ] - - if let gi = groupInfo, gi.canDelete { + var buttons: [Alert.Button] = [] + buttons.append(.default(Text("Clear")) { + AlertManager.shared.showAlert(clearChatAlert()) + }) + + if let groupInfo, chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator && groupInfo.ready { + buttons.append(.default(Text("Archive reports")) { + AlertManager.shared.showAlert(archiveAllReportsAlert()) + }) + } + + if canShowGroupDelete, let gi = groupInfo, gi.canDelete { buttons.append(.destructive(Text("Delete")) { AlertManager.shared.showAlert(deleteGroupAlert(gi)) }) @@ -372,7 +394,7 @@ struct ChatListNavLink: View { actionSheet = SomeActionSheet( actionSheet: ActionSheet( - title: Text("Clear or delete group?"), + title: canShowGroupDelete ? Text("Clear or delete group?") : Text("Clear group?"), buttons: buttons ), id: "other options" @@ -411,6 +433,7 @@ struct ChatListNavLink: View { private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } @@ -429,7 +452,6 @@ struct ChatListNavLink: View { } .tint(.red) } - .frame(height: dynamicRowHeight) .contentShape(Rectangle()) .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { @@ -441,6 +463,7 @@ struct ChatListNavLink: View { private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { ContactConnectionView(chat: chat) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in @@ -458,14 +481,11 @@ struct ChatListNavLink: View { } .tint(theme.colors.primary) } - .frame(height: dynamicRowHeight) .appSheet(isPresented: $showContactConnectionInfo) { - Group { - if case let .contactConnection(contactConnection) = chat.chatInfo { - ContactConnectionInfo(contactConnection: contactConnection) - .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) - .modifier(ThemedBackground(grouped: true)) - } + if case let .contactConnection(contactConnection) = chat.chatInfo { + ContactConnectionInfo(contactConnection: contactConnection) + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + .modifier(ThemedBackground(grouped: true)) } } .contentShape(Rectangle()) @@ -490,6 +510,27 @@ struct ChatListNavLink: View { ) } + private func archiveAllReportsAlert() -> Alert { + Alert( + title: Text("Archive all reports?"), + message: Text("All reports will be archived for you."), + primaryButton: .destructive(Text("Archive")) { + Task { await archiveAllReportsForMe(chat.chatInfo.apiId) } + }, + secondaryButton: .cancel() + ) + } + + private func archiveAllReportsForMe(_ apiId: Int64) async { + do { + if case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member) = try await apiArchiveReceivedReports(groupId: apiId) { + await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member) + } + } catch { + logger.error("archiveAllReportsForMe error: \(responseError(error))") + } + } + private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), @@ -536,14 +577,14 @@ struct ChatListNavLink: View { ) } - private func invalidJSONPreview(_ json: String) -> some View { + private func invalidJSONPreview(_ json: Data?) -> some View { Text("invalid chat data") .foregroundColor(.red) .padding(4) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .onTapGesture { showInvalidJSON = true } .appSheet(isPresented: $showInvalidJSON) { - invalidJSONView(json) + invalidJSONView(dataToString(json)) .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } } @@ -552,8 +593,27 @@ struct ChatListNavLink: View { Task { let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) if ok { - ItemsModel.shared.loadOpenChat(contact.id) - AlertManager.shared.showAlert(connReqSentAlert(.contact)) + ItemsModel.shared.loadOpenChat(contact.id) { + AlertManager.shared.showAlert(connReqSentAlert(.contact)) + } + } + } + } +} + +extension View { + @inline(__always) + @ViewBuilder fileprivate func frameCompat(height: CGFloat) -> some View { + if #available(iOS 16, *) { + self.frame(height: height) + } else { + VStack(spacing: 0) { + Divider() + .padding(.leading, 16) + self + .frame(height: height) + .padding(.horizontal, 8) + .padding(.vertical, 8) } } } @@ -645,7 +705,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { } func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { - if let r = error as? ChatResponse, + if let r = error as? ChatError, let alert = getNetworkErrorAlert(r) { return alert } else { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 68e0c57c75..377764ac83 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -148,7 +148,11 @@ struct ChatListView: View { @State private var userPickerShown: Bool = false @State private var sheet: SomeSheet? = nil @StateObject private var chatTagsModel = ChatTagsModel.shared - + @State private var scrollToItemId: ChatItem.ID? = nil + + // iOS 15 is required it to show/hide toolbar while chat is hidden/visible + @State private var viewOnScreen = true + @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @@ -203,7 +207,17 @@ struct ChatListView: View { .navigationBarHidden(searchMode || oneHandUI) } .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .onDisappear() { activeUserPickerSheet = nil } + .onAppear { + if #unavailable(iOS 16.0), !viewOnScreen { + viewOnScreen = true + } + } + .onDisappear { + activeUserPickerSheet = nil + if #unavailable(iOS 16.0) { + viewOnScreen = false + } + } .refreshable { AlertManager.shared.showAlert(Alert( title: Text("Reconnect servers?"), @@ -258,7 +272,7 @@ struct ChatListView: View { } } else { if oneHandUI { - content().toolbar { bottomToolbarGroup } + content().toolbar { bottomToolbarGroup() } } else { content().toolbar { topToolbar } } @@ -286,9 +300,9 @@ struct ChatListView: View { } } - @ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent { + @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 - ToolbarItemGroup(placement: .bottomBar) { + ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) { leadingToolbarItem.padding(.bottom, padding) Spacer() SubsStatusIndicator().padding(.bottom, padding) @@ -322,9 +336,9 @@ struct ChatListView: View { } } - @ViewBuilder private var chatList: some View { + private var chatList: some View { let cs = filteredChats() - ZStack { + return ZStack { ScrollViewReader { scrollProxy in List { if !chatModel.chats.isEmpty { @@ -354,13 +368,7 @@ struct ChatListView: View { .offset(x: -8) } else { ForEach(cs, id: \.viewId) { chat in - VStack(spacing: .zero) { - Divider() - .padding(.leading, 16) - ChatListNavLink(chat: chat, parentSheet: $sheet) - .padding(.horizontal, 8) - .padding(.vertical, 6) - } + ChatListNavLink(chat: chat, parentSheet: $sheet) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) @@ -439,7 +447,14 @@ struct ChatListView: View { @ViewBuilder private func chatView() -> some View { if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { - ChatView(chat: chat) + let im = ItemsModel.shared + ChatView( + chat: chat, + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im), + scrollToItemId: $scrollToItemId + ) } } @@ -480,7 +495,7 @@ struct ChatListView: View { switch chatTagsModel.activeFilter { case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true - case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + case .unread: chat.unreadTag case .none: true } } @@ -791,7 +806,7 @@ struct TagsView: View { } } - @ViewBuilder private func expandedPresetTagsFiltersView() -> some View { + private func expandedPresetTagsFiltersView() -> some View { ForEach(PresetTag.allCases, id: \.id) { tag in if (chatTagsModel.presetTags[tag] ?? 0) > 0 { expandedTagFilterView(tag) @@ -885,12 +900,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted case .contactRequest: true case .contactConnection: true - case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer + case let .group(groupInfo, _): groupInfo.businessChat?.chatType == .customer default: false } case .groups: switch chatInfo { - case let .group(groupInfo): groupInfo.businessChat == nil + case let .group(groupInfo, _): groupInfo.businessChat == nil default: false } case .business: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 654bb56441..49f629d084 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -27,72 +27,80 @@ struct ChatPreviewView: View { var body: some View { let cItem = chat.chatItems.last - return HStack(spacing: 8) { - ZStack(alignment: .bottomTrailing) { - ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) - chatPreviewImageOverlayIcon() - .padding([.bottom, .trailing], 1) - } - .padding(.leading, 4) - - let chatTs = if let cItem { - cItem.meta.itemTs - } else { - chat.chatInfo.chatTs - } - VStack(spacing: 0) { - HStack(alignment: .top) { - chatPreviewTitle() - Spacer() - (formatTimestampText(chatTs)) - .font(.subheadline) - .frame(minWidth: 60, alignment: .trailing) - .foregroundColor(theme.colors.secondary) - .padding(.top, 4) + return ZStack { + HStack(spacing: 8) { + ZStack(alignment: .bottomTrailing) { + ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) + chatPreviewImageOverlayIcon() + .padding([.bottom, .trailing], 1) } - .padding(.bottom, 4) - .padding(.horizontal, 8) - - ZStack(alignment: .topTrailing) { - let chat = activeContentPreview?.chat ?? chat - let ci = activeContentPreview?.ci ?? chat.chatItems.last - let mc = ci?.content.msgContent - HStack(alignment: .top) { - let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil - let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil - if let ci, showContentPreview { - chatItemContentPreview(chat, ci) - } - let mcIsVoice = switch mc { case .voice: true; default: false } - if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id { - let hasFilePreview = if case .file = mc { true } else { false } - chatMessagePreview(cItem, hasFilePreview) - } else { - Spacer() - chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing) - } - } - .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in - checkActiveContentPreview(chat, ci, mc) - } - .onChange(of: activeContentPreview) { _ in - checkActiveContentPreview(chat, ci, mc) - } - .onChange(of: showFullscreenGallery) { _ in - checkActiveContentPreview(chat, ci, mc) - } - chatStatusImage() - .padding(.top, dynamicChatInfoSize * 1.44) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 8) + .padding(.leading, 4) - Spacer() + let chatTs = if let cItem { + cItem.meta.itemTs + } else { + chat.chatInfo.chatTs + } + VStack(spacing: 0) { + HStack(alignment: .top) { + chatPreviewTitle() + Spacer() + (formatTimestampText(chatTs)) + .font(.subheadline) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(theme.colors.secondary) + .padding(.top, 4) + } + .padding(.bottom, 4) + .padding(.horizontal, 8) + + ZStack(alignment: .topTrailing) { + let chat = activeContentPreview?.chat ?? chat + let ci = activeContentPreview?.ci ?? chat.chatItems.last + let mc = ci?.content.msgContent + HStack(alignment: .top) { + let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil + let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil + if let ci, showContentPreview { + chatItemContentPreview(chat, ci) + } + let mcIsVoice = switch mc { case .voice: true; default: false } + if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id { + let hasFilePreview = if case .file = mc { true } else { false } + chatMessagePreview(cItem, hasFilePreview) + } else { + Spacer() + chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing) + } + } + .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in + checkActiveContentPreview(chat, ci, mc) + } + .onChange(of: activeContentPreview) { _ in + checkActiveContentPreview(chat, ci, mc) + } + .onChange(of: showFullscreenGallery) { _ in + checkActiveContentPreview(chat, ci, mc) + } + chatStatusImage() + .padding(.top, dynamicChatInfoSize * 1.44) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 8) + + Spacer() + } + .frame(maxHeight: .infinity) + } + .opacity(deleting ? 0.4 : 1) + .padding(.bottom, -8) + + if deleting { + ProgressView() + .scaleEffect(2) } - .frame(maxHeight: .infinity) } - .padding(.bottom, -8) .onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in deleting = contains // Stop voice when deleting the chat @@ -133,8 +141,9 @@ struct ChatPreviewView: View { } else { EmptyView() } - case let .group(groupInfo): + case let .group(groupInfo, _): switch (groupInfo.membership.memberStatus) { + case .memRejected: inactiveIcon() case .memLeft: inactiveIcon() case .memRemoved: inactiveIcon() case .memGroupDeleted: inactiveIcon() @@ -145,7 +154,7 @@ struct ChatPreviewView: View { } } - @ViewBuilder private func inactiveIcon() -> some View { + private func inactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) @@ -156,11 +165,11 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil) - case let .group(groupInfo): + case let .group(groupInfo, _): let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary) - case .memAccepted: v.foregroundColor(theme.colors.secondary) + case .memAccepted, .memRejected: v.foregroundColor(theme.colors.secondary) default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v } } default: previewTitle(t) @@ -178,14 +187,17 @@ struct ChatPreviewView: View { .kerning(-2) } - private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View { + private func chatPreviewLayout(_ text: Text?, draft: Bool = false, hasFilePreview: Bool = false, hasSecrets: Bool) -> some View { ZStack(alignment: .topTrailing) { + let s = chat.chatStats + let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 } let t = text .lineLimit(userFont <= .xxxLarge ? 2 : 1) .multilineTextAlignment(.leading) + .if(hasSecrets, transform: hiddenSecretsView) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, hasFilePreview ? 0 : 8) - .padding(.trailing, hasFilePreview ? 38 : 36) + .padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36)) .offset(x: hasFilePreview ? -2 : 0) .fixedSize(horizontal: false, vertical: true) if !showChatPreviews && !draft { @@ -200,19 +212,34 @@ struct ChatPreviewView: View { @ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View { let s = chat.chatStats if s.unreadCount > 0 || s.unreadChat { - unreadCountText(s.unreadCount) - .font(userFont <= .xxxLarge ? .caption : .caption2) - .foregroundColor(.white) - .padding(.horizontal, dynamicSize(userFont).unreadPadding) - .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) - .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) - .cornerRadius(dynamicSize(userFont).unreadCorner) - } else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local { - Image(systemName: "speaker.slash.fill") + let mentionColor = mentionColor(chat) + HStack(alignment: .center, spacing: 2) { + if s.unreadMentions > 0 && s.unreadCount > 1 { + Text("\(MENTION_START)") + .font(userFont <= .xxxLarge ? .body : .callout) + .foregroundColor(mentionColor) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .cornerRadius(dynamicSize(userFont).unreadCorner) + .padding(.bottom, 1) + } + let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1 + (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount)) + .font(userFont <= .xxxLarge ? .caption : .caption2) + .foregroundColor(.white) + .padding(.horizontal, dynamicSize(userFont).unreadPadding) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) + .cornerRadius(dynamicSize(userFont).unreadCorner) + } + .frame(height: dynamicChatInfoSize) + } else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all { + let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize + let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary + Image(systemName: ntfMode.iconFilled) .resizable() .scaledToFill() - .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) - .foregroundColor(theme.colors.secondary) + .frame(width: iconSize, height: iconSize) + .foregroundColor(iconColor) } else if chat.chatInfo.chatSettings?.favorite ?? false { Image(systemName: "star.fill") .resizable() @@ -224,12 +251,22 @@ struct ChatPreviewView: View { Color.clear.frame(width: 0) } } + + private func mentionColor(_ chat: Chat) -> Color { + switch chat.chatInfo.chatSettings?.enableNtfs { + case .all: theme.colors.primary + case .mentions: theme.colors.primary + default: theme.colors.secondary + } + } - private func messageDraft(_ draft: ComposeState) -> Text { + private func messageDraft(_ draft: ComposeState) -> (Text, Bool) { let msg = draft.message - return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) - + attachment() - + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background)) + return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) + + attachment() + + Text(AttributedString(r.string)), + r.hasSecrets) func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { Text(Image(systemName: s)).foregroundColor(color) + textSpace @@ -245,10 +282,11 @@ struct ChatPreviewView: View { } } - func chatItemPreview(_ cItem: ChatItem) -> Text { + func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) + let r = messageText(itemText, itemFormattedText, sender: cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) + return (Text(AttributedString(r.string)), r.hasSecrets) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type @@ -275,19 +313,21 @@ struct ChatPreviewView: View { } } - func prefix() -> Text { + func prefix() -> NSAttributedString? { switch cItem.content.msgContent { - case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) - default: return Text("") + case let .report(_, reason): reason.attrString + default: nil } } } @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { if chatModel.draftChatId == chat.id, let draft = chatModel.draft { - chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview) + let (t, hasSecrets) = messageDraft(draft) + chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets) } else if let cItem = cItem { - chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview) + let (t, hasSecrets) = chatItemPreview(cItem) + chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets) } else { switch (chat.chatInfo) { case let .direct(contact): @@ -301,10 +341,12 @@ struct ChatPreviewView: View { chatPreviewInfoText("connecting…") } } - case let .group(groupInfo): + case let .group(groupInfo, _): switch (groupInfo.membership.memberStatus) { + case .memRejected: chatPreviewInfoText("rejected") case .memInvited: groupInvitationPreviewText(groupInfo) case .memAccepted: chatPreviewInfoText("connecting…") + case .memPendingReview, .memPendingApproval: chatPreviewInfoText("reviewed by admins") default: EmptyView() } default: EmptyView() @@ -313,7 +355,6 @@ struct ChatPreviewView: View { } @ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View { - let linkClicksEnabled = privacyChatListOpenLinksDefault.get() != PrivacyChatListOpenLinksMode.no let mc = ci.content.msgContent switch mc { case let .link(_, preview): @@ -335,28 +376,16 @@ struct ChatPreviewView: View { .cornerRadius(8) } .onTapGesture { - switch privacyChatListOpenLinksDefault.get() { - case .yes: UIApplication.shared.open(preview.uri) - case .no: ItemsModel.shared.loadOpenChat(chat.id) - case .ask: AlertManager.shared.showAlert( - Alert(title: Text("Open web link?"), - message: Text(preview.uri.absoluteString), - primaryButton: .default(Text("Open chat"), action: { ItemsModel.shared.loadOpenChat(chat.id) }), - secondaryButton: .default(Text("Open link"), action: { UIApplication.shared.open(preview.uri) }) - ) - ) - } + openBrowserAlert(uri: preview.uri) } } case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) - .environmentObject(ReverseListScrollModel()) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) - .environmentObject(ReverseListScrollModel()) } case let .voice(_, duration): smallContentPreviewVoice(size: dynamicMediaSize) { @@ -377,7 +406,7 @@ struct ChatPreviewView: View { : chatPreviewInfoText("you are invited to group") } - @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { + private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { Text(text) .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding([.leading, .trailing], 8) @@ -411,7 +440,11 @@ struct ChatPreviewView: View { if progressByTimeout { ProgressView() } else if chat.chatStats.reportsCount > 0 { - groupReportsIcon(size: size * 0.8) + flagIcon(size: size * 0.8, color: .red) + } else if chat.supportUnreadCount > 0 { + flagIcon(size: size * 0.8, color: theme.colors.primary) + } else if chat.chatInfo.groupInfo?.membership.memberPending ?? false { + flagIcon(size: size * 0.8, color: theme.colors.secondary) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } @@ -457,12 +490,12 @@ struct ChatPreviewView: View { } } -@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View { +func flagIcon(size: CGFloat, color: Color) -> some View { Image(systemName: "flag") .resizable() .scaledToFit() .frame(width: size, height: size) - .foregroundColor(.red) + .foregroundColor(color) } func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { 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/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index aa802c1af9..8b0a8af888 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -245,7 +245,7 @@ struct ServersSummaryView: View { } } - @ViewBuilder private func smpServersListView( + private func smpServersListView( _ servers: [SMPServerSummary], _ statsStartedAt: Date, _ header: LocalizedStringKey? = nil, @@ -256,7 +256,7 @@ struct ServersSummaryView: View { ? serverAddress($0.smpServer) < serverAddress($1.smpServer) : $0.hasSubs && !$1.hasSubs } - Section { + return Section { ForEach(sortedServers) { server in smpServerView(server, statsStartedAt) } @@ -318,14 +318,14 @@ struct ServersSummaryView: View { return onionHosts == .require ? .indigo : .accentColor } - @ViewBuilder private func xftpServersListView( + private func xftpServersListView( _ servers: [XFTPServerSummary], _ statsStartedAt: Date, _ header: LocalizedStringKey? = nil, _ footer: LocalizedStringKey? = nil ) -> some View { let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) } - Section { + return Section { ForEach(sortedServers) { server in xftpServerView(server, statsStartedAt) } @@ -587,7 +587,7 @@ struct SMPStatsView: View { } header: { Text("Statistics") } footer: { - Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.") + Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.") } } } @@ -703,7 +703,7 @@ struct XFTPStatsView: View { } header: { Text("Statistics") } footer: { - Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.") + Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.") } } } diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift index 8811234f52..2063fe15de 100644 --- a/apps/ios/Shared/Views/ChatList/TagListView.swift +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -61,7 +61,7 @@ struct TagListView: View { Button { showAlert( NSLocalizedString("Delete list?", comment: "alert title"), - message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), + message: String.localizedStringWithFormat(NSLocalizedString("All chats will be removed from the list %@, and the list deleted.", comment: "alert message"), text), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), @@ -138,7 +138,7 @@ struct TagListView: View { } } - @ViewBuilder private func radioButton(selected: Bool) -> some View { + private func radioButton(selected: Bool) -> some View { Image(systemName: selected ? "checkmark.circle.fill" : "circle") .imageScale(.large) .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index dbe10ad997..c38ddfb1da 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -124,7 +124,7 @@ struct UserPicker: View { ZStack(alignment: .topTrailing) { ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) if (u.unreadCount > 0) { - UnreadBadge(userInfo: u).offset(x: 4, y: -4) + userUnreadBadge(u, theme: theme).offset(x: 4, y: -4) } } .padding(.trailing, 6) @@ -171,19 +171,27 @@ struct UserPicker: View { } } +@inline(__always) +func userUnreadBadge(_ userInfo: UserInfo, theme: AppTheme) -> some View { + UnreadBadge( + count: userInfo.unreadCount, + color: userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary + ) +} + struct UnreadBadge: View { - var userInfo: UserInfo - @EnvironmentObject var theme: AppTheme @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var count: Int + var color: Color var body: some View { let size = dynamicSize(userFont).chatInfoSize - unreadCountText(userInfo.unreadCount) + unreadCountText(count) .font(userFont <= .xxxLarge ? .caption : .caption2) .foregroundColor(.white) .padding(.horizontal, dynamicSize(userFont).unreadPadding) .frame(minWidth: size, minHeight: size) - .background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary) + .background(color) .cornerRadius(dynamicSize(userFont).unreadCorner) } } diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index 242b492e83..456c46d318 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -140,9 +140,9 @@ struct ContactListNavLink: View { } } - @ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { + private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) - ( + return ( contact.verified == true ? verifiedIcon + t : t @@ -188,8 +188,7 @@ struct ContactListNavLink: View { Task { let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") }) if ok { - ItemsModel.shared.loadOpenChat(contact.id) - DispatchQueue.main.async { + ItemsModel.shared.loadOpenChat(contact.id) { dismissAllSheets(animated: true) { AlertManager.shared.showAlert(connReqSentAlert(.contact)) } diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 3cd37e4930..441a164f8a 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -173,7 +173,7 @@ struct DatabaseEncryptionView: View { } return true } catch let error { - if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { + if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError { await operationEnded(.currentPassphraseError) } else { await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))")) diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 6222a28fb4..02a1b87826 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -28,7 +28,7 @@ struct DatabaseErrorView: View { } } - @ViewBuilder private func databaseErrorView() -> some View { + private func databaseErrorView() -> some View { VStack(alignment: .center, spacing: 20) { switch status { case let .errorNotADatabase(dbFile): @@ -141,7 +141,7 @@ struct DatabaseErrorView: View { } private func migrationsText(_ ms: [String]) -> some View { - (Text("Migrations:").font(.subheadline) + Text(verbatim: "\n") + Text(ms.joined(separator: "\n")).font(.caption)) + (Text("Migrations:").font(.subheadline) + textNewLine + Text(ms.joined(separator: "\n")).font(.caption)) .multilineTextAlignment(.center) .padding(.horizontal, 25) } diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 4c05434eb6..a7e61b3105 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -21,7 +21,7 @@ enum DatabaseAlert: Identifiable { case deleteLegacyDatabase case deleteFilesAndMedia case setChatItemTTL(ttl: ChatItemTTL) - case error(title: LocalizedStringKey, error: String = "") + case error(title: String, error: String = "") var id: String { switch self { @@ -279,7 +279,7 @@ struct DatabaseView: View { case let .archiveExportedWithErrors(archivePath, errs): return Alert( title: Text("Chat database exported"), - message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), + message: Text("You may save the exported archive.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), dismissButton: .default(Text("Continue")) { showShareSheet(items: [archivePath]) } @@ -456,7 +456,7 @@ struct DatabaseView: View { } } catch let error { await MainActor.run { - alert = .error(title: "Error exporting chat database", error: responseError(error)) + alert = .error(title: NSLocalizedString("Error exporting chat database", comment: "alert title"), error: responseError(error)) progressIndicator = false } } @@ -492,10 +492,10 @@ struct DatabaseView: View { return migration } } catch let error { - await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert) + await operationEnded(.error(title: NSLocalizedString("Error importing chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert) } } catch let error { - await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert) + await operationEnded(.error(title: NSLocalizedString("Error deleting chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert) } } else { showAlert("Error accessing database file") @@ -513,7 +513,7 @@ struct DatabaseView: View { await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert) return true } catch let error { - await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert) + await DatabaseView.operationEnded(.error(title: NSLocalizedString("Error deleting database", comment: "alert title"), error: responseError(error)), $progressIndicator, $alert) return false } } @@ -522,7 +522,7 @@ struct DatabaseView: View { if removeLegacyDatabaseAndFiles() { legacyDatabase = false } else { - alert = .error(title: "Error deleting old database") + alert = .error(title: NSLocalizedString("Error deleting old database", comment: "alert title")) } } @@ -546,7 +546,7 @@ struct DatabaseView: View { let (title, message) = chatDeletedAlertText() showAlert(title, message: message, actions: { [okAlertActionWaiting] }) } else if case let .error(title, error) = dbAlert { - showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] }) + showAlert(title, message: error, actions: { [okAlertActionWaiting] }) } else { alert.wrappedValue = dbAlert cont.resume() @@ -567,7 +567,7 @@ struct DatabaseView: View { } } catch { await MainActor.run { - alert = .error(title: "Error changing setting", error: responseError(error)) + alert = .error(title: NSLocalizedString("Error changing setting", comment: "alert title"), error: responseError(error)) chatItemTTL = currentChatItemTTL afterSetCiTTL() } diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 1e334367e8..17fe95a058 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -33,7 +33,7 @@ extension View { func appSheet( isPresented: Binding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + @ViewBuilder content: @escaping () -> Content ) -> some View where Content: View { sheet(isPresented: isPresented, onDismiss: onDismiss) { content().modifier(PrivacySensitive()) @@ -43,7 +43,7 @@ extension View { func appSheet( item: Binding, onDismiss: (() -> Void)? = nil, - content: @escaping (T) -> Content + @ViewBuilder content: @escaping (T) -> Content ) -> some View where T: Identifiable, Content: View { sheet(item: item, onDismiss: onDismiss) { it in content(it).modifier(PrivacySensitive()) diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift index c790b9cff2..85ef85c611 100644 --- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift +++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift @@ -9,6 +9,7 @@ import SwiftUI extension View { + @inline(__always) @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { if condition { transform(self) @@ -36,9 +37,9 @@ struct PrivacyBlur: ViewModifier { .overlay { if (blurred && enabled) { Color.clear.contentShape(Rectangle()) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { blurred = false - } + }) } } .onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 27bb95b599..c21ff9be8b 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -65,6 +65,9 @@ struct LocalAuthView: View { // Clear sensitive data on screen just in case app fails to hide its views while new database is created m.chatId = nil ItemsModel.shared.reversedChatItems = [] + ItemsModel.shared.chatState.clear() + ChatModel.shared.secondaryIM?.reversedChatItems = [] + ChatModel.shared.secondaryIM?.chatState.clear() m.updateChats([]) m.users = [] _ = kcAppPassword.set(password) diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift index 609943bcb6..4a6f8e7549 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift @@ -28,7 +28,7 @@ struct PasscodeEntry: View { } } - @ViewBuilder private func passwordView() -> some View { + private func passwordView() -> some View { Text( password == "" ? " " diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index eb8df5fb04..0af8fa7ad8 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -177,7 +177,7 @@ struct MigrateFromDevice: View { case let .archiveExportedWithErrors(archivePath, errs): return Alert( title: Text("Chat database exported"), - message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), + message: Text("You may migrate the exported database.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), dismissButton: .default(Text("Continue")) { Task { await uploadArchive(path: archivePath) } } @@ -520,15 +520,15 @@ struct MigrateFromDevice: View { chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in await MainActor.run { switch msg { - case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize): + case let .result(.sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize)): if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total { migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl) } - case .sndFileRedirectStartXFTP: + case .result(.sndFileRedirectStartXFTP): DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { migrationState = .linkCreation } - case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs): + case let .result(.sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs)): let cfg = getNetCfg() let proxy: NetworkProxy? = if cfg.socksProxy == nil { nil @@ -546,7 +546,7 @@ struct MigrateFromDevice: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) } - case .sndFileError: + case .result(.sndFileError): alert = .error(title: "Upload failed", error: "Check your internet connection and try again") migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) default: @@ -691,7 +691,7 @@ private struct PassphraseConfirmationView: View { migrationState = .uploadConfirmation } } catch let error { - if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse { + if case .errorDatabase(.errorOpen(.errorNotADatabase)) = error as? ChatError { showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) } else { alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error))) @@ -733,11 +733,11 @@ func chatStoppedView() -> some View { private class MigrationChatReceiver { let ctrl: chat_ctrl let databaseUrl: URL - let processReceivedMsg: (ChatResponse) async -> Void + let processReceivedMsg: (APIResult) async -> Void private var receiveLoop: Task? private var receiveMessages = true - init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult) async -> Void) { self.ctrl = ctrl self.databaseUrl = databaseUrl self.processReceivedMsg = processReceivedMsg @@ -752,9 +752,9 @@ private class MigrationChatReceiver { func receiveMsgLoop() async { // TODO use function that has timeout - if let msg = await chatRecvMsg(ctrl) { + if let msg: APIResult = await chatRecvMsg(ctrl) { Task { - await TerminalItems.shared.add(.resp(.now, msg)) + await TerminalItems.shared.addResult(msg) } logger.debug("processReceivedMsg: \(msg.responseType)") await processReceivedMsg(msg) diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 2d83cdc7c8..93fe19cf33 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -496,10 +496,10 @@ struct MigrateToDevice: View { chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in await MainActor.run { switch msg { - case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer): + case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)): migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl) MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) - case .rcvStandaloneFileComplete: + case .result(.rcvStandaloneFileComplete): DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // User closed the whole screen before new state was saved if migrationState == nil { @@ -509,10 +509,10 @@ struct MigrateToDevice: View { MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) } } - case .rcvFileError: + case .result(.rcvFileError): alert = .error(title: "Download failed", error: "File was deleted or link is invalid") migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) - case .chatError(_, .error(.noRcvFileUser)): + case .error(.error(.noRcvFileUser)): alert = .error(title: "Download failed", error: "File was deleted or link is invalid") migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) default: @@ -539,7 +539,7 @@ struct MigrateToDevice: View { chatInitControllerRemovingDatabases() } else if ChatModel.shared.chatRunning == true { // cannot delete storage if chat is running - try await apiStopChat() + try await stopChatAsync() } try await apiDeleteStorage() try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) @@ -623,7 +623,7 @@ struct MigrateToDevice: View { AlertManager.shared.showAlert( Alert( title: Text("Error migrating settings"), - message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error))) + message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error))) ) } hideView() @@ -632,6 +632,8 @@ struct MigrateToDevice: View { private func hideView() { onboardingStageDefault.set(.onboardingComplete) m.onboardingStage = .onboardingComplete + m.migrationState = nil + MigrationToDeviceState.save(nil) dismiss() } @@ -749,11 +751,11 @@ private func progressView() -> some View { private class MigrationChatReceiver { let ctrl: chat_ctrl let databaseUrl: URL - let processReceivedMsg: (ChatResponse) async -> Void + let processReceivedMsg: (APIResult) async -> Void private var receiveLoop: Task? private var receiveMessages = true - init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult) async -> Void) { self.ctrl = ctrl self.databaseUrl = databaseUrl self.processReceivedMsg = processReceivedMsg @@ -770,7 +772,7 @@ private class MigrationChatReceiver { // TODO use function that has timeout if let msg = await chatRecvMsg(ctrl) { Task { - await TerminalItems.shared.add(.resp(.now, msg)) + await TerminalItems.shared.addResult(msg) } logger.debug("processReceivedMsg: \(msg.responseType)") await processReceivedMsg(msg) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 0c7f6136ff..b3c33e95ea 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 { @@ -191,13 +191,9 @@ struct AddGroupView: View { profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on)) let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - m.groupMembers = groupMembers.map { GMember.init($0) } - m.populateGroupMembersIndexes() - } + await m.loadGroupMembers(gInfo) } - let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) + let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: []) m.addChat(c) withAnimation { groupInfo = gInfo diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 39656c1534..98f43f49b2 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -85,7 +85,7 @@ struct NewChatSheet: View { } } - @ViewBuilder private func viewBody(_ showArchive: Bool) -> some View { + private func viewBody(_ showArchive: Bool) -> some View { List { HStack { ContactsListSearchBar( @@ -125,7 +125,7 @@ struct NewChatSheet: View { } NavigationLink { AddGroupView() - .navigationTitle("Create secret group") + .navigationTitle("Create group") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { @@ -258,7 +258,7 @@ struct ContactsList: View { } } - @ViewBuilder private func noResultSection(text: String) -> some View { + private func noResultSection(text: String) -> some View { Section { Text(text) .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 6e898f4cdf..110eda7882 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) } @@ -502,7 +506,7 @@ private struct ActiveProfilePicker: View { } } - @ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View { + private func profilerPickerUserOption(_ user: User) -> some View { Button { if selectedProfile == user && incognitoEnabled { incognitoEnabled = false @@ -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,39 +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) + 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) + 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?() + } } } } @@ -1269,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..c9054f30da 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -8,18 +8,30 @@ import SwiftUI import CoreImage.CIFilterBuiltins +import SimpleXChat struct MutableQRCode: View { @Binding var uri: String + var small: Bool = false var withLogo: Bool = true var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var body: some View { - QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor) + QRCode(uri: uri, small: small, withLogo: withLogo, tintColor: tintColor) .id("simplex-qrcode-view-for-\(uri)") } } +struct SimpleXCreatedLinkQRCode: View { + let link: CreatedConnLink + @Binding var short: Bool + var onShare: (() -> Void)? = nil + + var body: some View { + QRCode(uri: link.simplexChatUri(short: short), small: short && link.connShortLink != nil, onShare: onShare) + } +} + struct SimpleXLinkQRCode: View { let uri: String var withLogo: Bool = true @@ -27,56 +39,57 @@ struct SimpleXLinkQRCode: View { var onShare: (() -> Void)? = nil var body: some View { - QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor, onShare: onShare) + QRCode(uri: simplexChatLink(uri), small: uri.count < 200, withLogo: withLogo, tintColor: tintColor, onShare: onShare) } } -func simplexChatLink(_ uri: String) -> String { - uri.starts(with: "simplex:/") - ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") - : uri -} +private let smallQRRatio: CGFloat = 0.63 struct QRCode: View { let uri: String + var small: Bool = false var withLogo: Bool = true var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var onShare: (() -> Void)? = nil @State private var image: UIImage? = nil @State private var makeScreenshotFunc: () -> Void = {} + @State private var width: CGFloat = .infinity var body: some View { ZStack { if let image = image { - qrCodeImage(image) - GeometryReader { geo in + qrCodeImage(image).frame(width: width, height: width) + GeometryReader { g in + let w = g.size.width * (small ? smallQRRatio : 1) + let l = w * (small ? 0.195 : 0.16) + let m = w * 0.005 ZStack { if withLogo { - let w = geo.size.width Image("icon-light") .resizable() .scaledToFit() - .frame(width: w * 0.16, height: w * 0.16) - .frame(width: w * 0.165, height: w * 0.165) + .frame(width: l, height: l) + .frame(width: l + m, height: l + m) .background(.white) .clipShape(Circle()) } } .onAppear { + width = w makeScreenshotFunc = { let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale) - showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)]) + showShareSheet(items: [makeScreenshot(g.frame(in: .local).origin, size)]) onShare?() } } - .frame(width: geo.size.width, height: geo.size.height) + .frame(width: g.size.width, height: g.size.height) } } else { - Color.clear.aspectRatio(1, contentMode: .fit) + Color.clear.aspectRatio(small ? 1 / smallQRRatio : 1, contentMode: .fit) } } .onTapGesture(perform: makeScreenshotFunc) - .task { image = await generateImage(uri, tintColor: tintColor) } + .task { image = await generateImage(uri, tintColor: tintColor, errorLevel: small ? "M" : "L") } .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -89,10 +102,11 @@ private func qrCodeImage(_ image: UIImage) -> some View { .textSelection(.enabled) } -private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? { +private func generateImage(_ uri: String, tintColor: UIColor, errorLevel: String) async -> UIImage? { let context = CIContext() let filter = CIFilter.qrCodeGenerator() filter.message = Data(uri.utf8) + filter.correctionLevel = errorLevel if let outputImage = filter.outputImage, let cgImage = context.createCGImage(outputImage, from: outputImage.extent) { return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor) diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 1a0a736acd..656cef4a04 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -43,26 +43,23 @@ struct OnboardingButtonStyle: ButtonStyle { } } -private enum ChooseServerOperatorsSheet: Identifiable { - case showInfo +private enum OnboardingConditionsViewSheet: Identifiable { case showConditions + case configureOperators var id: String { switch self { - case .showInfo: return "showInfo" case .showConditions: return "showConditions" + case .configureOperators: return "configureOperators" } } } -struct ChooseServerOperators: View { - @Environment(\.dismiss) var dismiss: DismissAction - @Environment(\.colorScheme) var colorScheme: ColorScheme +struct OnboardingConditionsView: View { @EnvironmentObject var theme: AppTheme - var onboarding: Bool @State private var serverOperators: [ServerOperator] = [] @State private var selectedOperatorIds = Set() - @State private var sheetItem: ChooseServerOperatorsSheet? = nil + @State private var sheetItem: OnboardingConditionsViewSheet? = nil @State private var notificationsModeNavLinkActive = false @State private var justOpened = true @@ -70,83 +67,47 @@ struct ChooseServerOperators: View { var body: some View { GeometryReader { g in - ScrollView { + let v = ScrollView { VStack(alignment: .leading, spacing: 20) { - let title = Text("Server operators") + Text("Conditions of use") .font(.largeTitle) .bold() .frame(maxWidth: .infinity, alignment: .center) - - if onboarding { - title.padding(.top, 25) - } else { - title - } - - infoText() - .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 25) Spacer() - - ForEach(serverOperators) { srvOperator in - operatorCheckView(srvOperator) + + VStack(alignment: .leading, spacing: 20) { + Text("Private chats, groups and your contacts are not accessible to server operators.") + .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + Text(""" + By using SimpleX Chat you agree to: + - send only legal content in public groups. + - respect other users – no spam. + """) + .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + + Button("Privacy policy and conditions of use.") { + sheetItem = .showConditions + } + .frame(maxWidth: .infinity, alignment: .leading) } - VStack { - Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8) - Text("You can configure servers via settings.") - } - .font(.footnote) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 16) - + .padding(.horizontal, 4) + Spacer() - - let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed } - let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) - - VStack(spacing: 8) { - if !reviewForOperators.isEmpty { - reviewConditionsButton() - } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty { - setOperatorsButton() - } else { - continueButton() + + VStack(spacing: 12) { + acceptConditionsButton() + + Button("Configure server operators") { + sheetItem = .configureOperators } - if onboarding { - Group { - if reviewForOperators.isEmpty { - Button("Conditions of use") { - sheetItem = .showConditions - } - } else { - Text("Conditions of use") - .foregroundColor(.clear) - } - } - .font(.system(size: 17, weight: .semibold)) - .frame(minHeight: 40) - } - } - - if !onboarding && !reviewForOperators.isEmpty { - VStack(spacing: 8) { - reviewLaterButton() - ( - Text("Conditions will be accepted for enabled operators after 30 days.") - + textSpace - + Text("You can configure operators in Network & servers settings.") - ) - .multilineTextAlignment(.center) - .font(.footnote) - .padding(.horizontal, 32) - } - .frame(maxWidth: .infinity) - .disabled(!canReviewLater) - .padding(.bottom) + .frame(minHeight: 40) } } + .padding(25) .frame(minHeight: g.size.height) } .onAppear { @@ -158,130 +119,28 @@ struct ChooseServerOperators: View { } .sheet(item: $sheetItem) { item in switch item { - case .showInfo: - ChooseServerOperatorsInfoView() case .showConditions: - UsageConditionsView( - currUserServers: Binding.constant([]), - userServers: Binding.constant([]) - ) - .modifier(ThemedBackground(grouped: true)) + SimpleConditionsView() + .modifier(ThemedBackground(grouped: true)) + case .configureOperators: + ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds) + .modifier(ThemedBackground()) } } .frame(maxHeight: .infinity, alignment: .top) + if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } } .frame(maxHeight: .infinity, alignment: .top) - .padding(onboarding ? 25 : 16) - } - - private func infoText() -> some View { - Button { - sheetItem = .showInfo - } label: { - Label("How it helps privacy", systemImage: "info.circle") - .font(.headline) - } - } - - @ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View { - let checked = selectedOperatorIds.contains(serverOperator.operatorId) - let icon = checked ? "checkmark.circle.fill" : "circle" - let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) - HStack(spacing: 10) { - Image(serverOperator.largeLogo(colorScheme)) - .resizable() - .scaledToFit() - .frame(height: 48) - Spacer() - Image(systemName: icon) - .resizable() - .scaledToFit() - .frame(width: 26, height: 26) - .foregroundColor(iconColor) - } - .background(theme.colors.background) - .padding() - .clipShape(RoundedRectangle(cornerRadius: 18)) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2) - ) - .padding(.horizontal, 2) - .onTapGesture { - if checked { - selectedOperatorIds.remove(serverOperator.operatorId) - } else { - selectedOperatorIds.insert(serverOperator.operatorId) - } - } - } - - private func reviewConditionsButton() -> some View { - NavigationLink("Review conditions") { - reviewConditionsView() - .navigationTitle("Conditions of use") - .navigationBarTitleDisplayMode(.large) - .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } - .modifier(ThemedBackground(grouped: true)) - } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) - } - - private func setOperatorsButton() -> some View { - notificationsModeNavLinkButton { - Button { - Task { - if let enabledOperators = enabledOperators(serverOperators) { - let r = try await setServerOperators(operators: enabledOperators) - await MainActor.run { - ChatModel.shared.conditions = r - continueToNextStep() - } - } else { - await MainActor.run { - continueToNextStep() - } - } - } - } label: { - Text("Update") - } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) - } - } - - private func continueButton() -> some View { - notificationsModeNavLinkButton { - Button { - continueToNextStep() - } label: { - Text("Continue") - } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) - } - } - - private func reviewLaterButton() -> some View { - notificationsModeNavLinkButton { - Button { - continueToNextStep() - } label: { - Text("Review later") - } - .buttonStyle(.borderless) - } + .navigationBarHidden(true) // necessary on iOS 15 } private func continueToNextStep() { - if onboarding { - onboardingStageDefault.set(.step4_SetNotificationsMode) - notificationsModeNavLinkActive = true - } else { - dismiss() - } + onboardingStageDefault.set(.step4_SetNotificationsMode) + notificationsModeNavLinkActive = true } func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View { @@ -304,25 +163,6 @@ struct ChooseServerOperators: View { .modifier(ThemedBackground()) } - @ViewBuilder private func reviewConditionsView() -> some View { - let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted } - let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - VStack(alignment: .leading, spacing: 20) { - if !operatorsWithConditionsAccepted.isEmpty { - Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.") - Text("The same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") - } else { - Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") - } - ConditionsTextView() - .frame(maxHeight: .infinity) - acceptConditionsButton() - .padding(.bottom) - .padding(.bottom) - } - .padding(.horizontal, 25) - } - private func acceptConditionsButton() -> some View { notificationsModeNavLinkButton { Button { @@ -356,9 +196,10 @@ struct ChooseServerOperators: View { } } } label: { - Text("Accept conditions") + Text("Accept") } - .buttonStyle(OnboardingButtonStyle()) + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) } } @@ -393,6 +234,126 @@ struct ChooseServerOperators: View { } } +private enum ChooseServerOperatorsSheet: Identifiable { + case showInfo + + var id: String { + switch self { + case .showInfo: return "showInfo" + } + } +} + +struct ChooseServerOperators: View { + @Environment(\.dismiss) var dismiss: DismissAction + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + var serverOperators: [ServerOperator] + @Binding var selectedOperatorIds: Set + @State private var sheetItem: ChooseServerOperatorsSheet? = nil + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Server operators") + .font(.largeTitle) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 25) + + infoText() + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + + ForEach(serverOperators) { srvOperator in + operatorCheckView(srvOperator) + } + VStack { + Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8) + Text("You can configure servers via settings.") + } + .font(.footnote) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 16) + + Spacer() + + VStack(spacing: 8) { + setOperatorsButton() + onboardingButtonPlaceholder() + } + } + .frame(minHeight: g.size.height) + } + .sheet(item: $sheetItem) { item in + switch item { + case .showInfo: + ChooseServerOperatorsInfoView() + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + .frame(maxHeight: .infinity, alignment: .top) + .padding(25) + .interactiveDismissDisabled(selectedOperatorIds.isEmpty) + } + + private func infoText() -> some View { + Button { + sheetItem = .showInfo + } label: { + Label("How it helps privacy", systemImage: "info.circle") + .font(.headline) + } + } + + private func operatorCheckView(_ serverOperator: ServerOperator) -> some View { + let checked = selectedOperatorIds.contains(serverOperator.operatorId) + let icon = checked ? "checkmark.circle.fill" : "circle" + let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) + return HStack(spacing: 10) { + Image(serverOperator.largeLogo(colorScheme)) + .resizable() + .scaledToFit() + .frame(height: 48) + Spacer() + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 26, height: 26) + .foregroundColor(iconColor) + } + .background(theme.colors.background) + .padding() + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + .padding(.horizontal, 2) + .onTapGesture { + if checked { + selectedOperatorIds.remove(serverOperator.operatorId) + } else { + selectedOperatorIds.insert(serverOperator.operatorId) + } + } + } + + private func setOperatorsButton() -> some View { + Button { + dismiss() + } label: { + Text("OK") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + } +} + let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")! struct ChooseServerOperatorsInfoView: View { @@ -447,5 +408,5 @@ struct ChooseServerOperatorsInfoView: View { } #Preview { - ChooseServerOperators(onboarding: true) + OnboardingConditionsView() } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 409cb859ea..ae72cb1be5 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -62,8 +62,7 @@ struct CreateProfile: View { .frame(height: 20) } footer: { VStack(alignment: .leading, spacing: 8) { - Text("Your profile, contacts and delivered messages are stored on your device.") - Text("The profile is only shared with your contacts.") + Text("Your profile is stored on your device and only shared with your contacts.") } .foregroundColor(theme.colors.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -118,25 +117,22 @@ struct CreateFirstProfile: View { @State private var nextStepNavLinkActive = false var body: some View { - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .center, spacing: 20) { - Text("Create your profile") + let v = VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .center, spacing: 16) { + Text("Create profile") .font(.largeTitle) .bold() .multilineTextAlignment(.center) - - Text("Your profile, contacts and delivered messages are stored on your device.") - .font(.callout) - .foregroundColor(theme.colors.secondary) - .multilineTextAlignment(.center) - - Text("The profile is only shared with your contacts.") + + Text("Your profile is stored on your device and only shared with your contacts.") .font(.callout) .foregroundColor(theme.colors.secondary) .multilineTextAlignment(.center) } + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) // Ensures it takes up the full width .padding(.horizontal, 10) + .onTapGesture { focusDisplayName = false } HStack { let name = displayName.trimmingCharacters(in: .whitespaces) @@ -145,6 +141,7 @@ struct CreateFirstProfile: View { TextField("Enter your name…", text: $displayName) .focused($focusDisplayName) .padding(.horizontal) + .padding(.trailing, 20) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) @@ -173,12 +170,23 @@ struct CreateFirstProfile: View { } } .onAppear() { - focusDisplayName = true + if #available(iOS 16, *) { + focusDisplayName = true + } else { + // it does not work before animation completes on iOS 15 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + focusDisplayName = true + } + } } .padding(.horizontal, 25) - .padding(.top, 10) .padding(.bottom, 25) .frame(maxWidth: .infinity, alignment: .leading) + if #available(iOS 16, *) { + return v.padding(.top, 10) + } else { + return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top) + } } func createProfileButton() -> some View { @@ -206,7 +214,7 @@ struct CreateFirstProfile: View { } private func nextStepDestinationView() -> some View { - ChooseServerOperators(onboarding: true) + OnboardingConditionsView() .navigationBarBackButtonHidden(true) .modifier(ThemedBackground()) } @@ -235,15 +243,15 @@ private func showCreateProfileAlert( _ error: Error ) { let m = ChatModel.shared - switch error as? ChatResponse { - case .chatCmdError(_, .errorStore(.duplicateName)), - .chatCmdError(_, .error(.userExists)): + switch error as? ChatError { + case .errorStore(.duplicateName), + .error(.userExists): if m.currentUser == nil { AlertManager.shared.showAlert(duplicateUserAlert) } else { showAlert(.duplicateUserError) } - case .chatCmdError(_, .error(.invalidDisplayName)): + case .error(.invalidDisplayName): if m.currentUser == nil { AlertManager.shared.showAlert(invalidDisplayNameAlert) } else { 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/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index b2b1b8fa68..8f448dc508 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -23,7 +23,7 @@ struct OnboardingView: View { case .step3_CreateSimpleXAddress: // deprecated CreateSimpleXAddress() case .step3_ChooseServerOperators: - ChooseServerOperators(onboarding: true) + OnboardingConditionsView() .navigationBarBackButtonHidden(true) .modifier(ThemedBackground()) case .step4_SetNotificationsMode: @@ -44,7 +44,7 @@ enum OnboardingStage: String, Identifiable { case step1_SimpleXInfo case step2_CreateProfile // deprecated case step3_CreateSimpleXAddress // deprecated - case step3_ChooseServerOperators + case step3_ChooseServerOperators // changed to simplified conditions case step4_SetNotificationsMode case onboardingComplete diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 97e1f49382..31865e7af9 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -17,7 +17,7 @@ struct SetNotificationsMode: View { var body: some View { GeometryReader { g in - ScrollView { + let v = ScrollView { VStack(alignment: .center, spacing: 20) { Text("Push notifications") .font(.largeTitle) @@ -57,11 +57,17 @@ struct SetNotificationsMode: View { .padding(25) .frame(minHeight: g.size.height) } + if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } } .frame(maxHeight: .infinity) .sheet(isPresented: $showInfo) { NotificationsInfoView() } + .navigationBarHidden(true) // necessary on iOS 15 } private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 40dd29db53..9f41a37b1d 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -18,7 +18,7 @@ struct SimpleXInfo: View { var body: some View { GeometryReader { g in - ScrollView { + let v = ScrollView { VStack(alignment: .leading) { VStack(alignment: .center, spacing: 10) { Image(colorScheme == .light ? "logo" : "logo-light") @@ -36,7 +36,7 @@ struct SimpleXInfo: View { .font(.headline) } } - + Spacer() VStack(alignment: .leading) { @@ -66,6 +66,9 @@ struct SimpleXInfo: View { } } } + .padding(.horizontal, 25) + .padding(.top, 75) + .padding(.bottom, 25) .frame(minHeight: g.size.height) } .sheet(isPresented: Binding( @@ -88,14 +91,17 @@ struct SimpleXInfo: View { createProfileNavLinkActive: $createProfileNavLinkActive ) } + if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } } .onAppear() { setLastVersionDefault() } .frame(maxHeight: .infinity) - .padding(.horizontal, 25) - .padding(.top, 75) - .padding(.bottom, 25) + .navigationBarHidden(true) // necessary on iOS 15 } private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View { @@ -129,6 +135,7 @@ struct SimpleXInfo: View { NavigationLink(isActive: $createProfileNavLinkActive) { CreateFirstProfile() + .modifier(ThemedBackground()) } label: { EmptyView() } @@ -140,6 +147,8 @@ struct SimpleXInfo: View { let textSpace = Text(verbatim: " ") +let textNewLine = Text(verbatim: "\n") + struct SimpleXInfo_Previews: PreviewProvider { static var previews: some View { SimpleXInfo(onboarding: true) diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 182c5652d7..f65a21623a 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -539,7 +539,46 @@ private let versionDescriptions: [VersionDescription] = [ description: "Delivered even when Apple drops them." )), ] - ) + ), + VersionDescription( + version: "v6.3", + post: URL(string: "https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html"), + features: [ + .feature(Description( + icon: "at", + title: "Mention members 👋", + description: "Get notified when mentioned." + )), + .feature(Description( + icon: "flag", + title: "Send private reports", + description: "Help admins moderating their groups." + )), + .feature(Description( + icon: "list.bullet", + title: "Organize chats into lists", + description: "Don't miss important messages." + )), + .feature(Description( + icon: nil, + title: "Better privacy and security", + description: nil, + subfeatures: [ + ("eye.slash", "Private media file names."), + ("trash", "Set message expiration in chats.") + ] + )), + .feature(Description( + icon: nil, + title: "Better groups performance", + description: nil, + subfeatures: [ + ("bolt", "Faster sending messages."), + ("person.2.slash", "Faster deletion of groups.") + ] + )), + ] + ), ] private let lastVersion = versionDescriptions.last!.version @@ -555,8 +594,6 @@ func shouldShowWhatsNew() -> Bool { } fileprivate struct NewOperatorsView: View { - @State private var showOperatorsSheet = false - var body: some View { VStack(alignment: .leading) { Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo) @@ -567,16 +604,7 @@ fileprivate struct NewOperatorsView: View { .multilineTextAlignment(.leading) .lineLimit(10) HStack { - Button("Enable Flux") { - showOperatorsSheet = true - } - Text("for better metadata privacy.") - } - } - .sheet(isPresented: $showOperatorsSheet) { - NavigationView { - ChooseServerOperators(onboarding: false) - .modifier(ThemedBackground()) + Text("Enable Flux in Network & servers settings for better metadata privacy.") } } } diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 67020e09e7..01b25baed8 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -456,12 +456,12 @@ struct ConnectDesktopView: View { } } catch let e { await MainActor.run { - switch e as? ChatResponse { - case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError - case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError - case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v) - case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil) - case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError + switch e as? ChatError { + case .errorRemoteCtrl(.badInvitation): alert = .badInvitationError + case .error(.commandError): alert = .badInvitationError + case let .errorRemoteCtrl(.badVersion(v)): alert = .badVersionError(version: v) + case .errorAgent(.RCP(.version)): alert = .badVersionError(version: nil) + case .errorAgent(.RCP(.ctrlAuth)): alert = .desktopDisconnectedError default: errorAlert(e) } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 23e1f783f7..554219eb69 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -18,7 +18,9 @@ struct TerminalView: View { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State var composeState: ComposeState = ComposeState() + @State var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var terminalItem: TerminalItem? @State private var scrolled = false @@ -96,10 +98,12 @@ struct TerminalView: View { SendMessageView( composeState: $composeState, + selectedRange: $selectedRange, sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate ) .padding(.horizontal, 12) } @@ -141,18 +145,18 @@ struct TerminalView: View { } func consoleSendMessage() { - let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { - let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) + let resp: APIResult = APIResult.error(ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) Task { - await TerminalItems.shared.addCommand(.now, cmd, resp) + await TerminalItems.shared.addCommand(.now, .string(composeState.message), resp) } } else { + let cmd = composeState.message DispatchQueue.global().async { Task { - composeState.inProgress = true - _ = await chatSendCmd(cmd) - composeState.inProgress = false + await MainActor.run { composeState.inProgress = true } + await sendTerminalCmd(cmd) + await MainActor.run { composeState.inProgress = false } } } } @@ -160,12 +164,38 @@ struct TerminalView: View { } } +func sendTerminalCmd(_ cmd: String) async { + let start: Date = .now + await withCheckedContinuation { (cont: CheckedContinuation) in + let d = sendSimpleXCmdStr(cmd) + Task { + guard let d else { + await TerminalItems.shared.addCommand(start, ChatCommand.string(cmd), APIResult.error(.invalidJSON(json: nil))) + return + } + let r0: APIResult = decodeAPIResult(d) + guard case .invalid = r0 else { + await TerminalItems.shared.addCommand(start, .string(cmd), r0) + return + } + let r1: APIResult = decodeAPIResult(d) + guard case .invalid = r1 else { + await TerminalItems.shared.addCommand(start, .string(cmd), r1) + return + } + let r2: APIResult = decodeAPIResult(d) + await TerminalItems.shared.addCommand(start, .string(cmd), r2) + } + cont.resume(returning: ()) + } +} + struct TerminalView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.terminalItems = [ - .resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), - .resp(.now, ChatResponse.response(type: "newChatItems", json: "{}")) + .err(.now, APIResult.invalid(type: "contactSubscribed", json: "{}".data(using: .utf8)!).unexpected), + .err(.now, APIResult.invalid(type: "newChatItems", json: "{}".data(using: .utf8)!).unexpected) ] return NavigationView { TerminalView() diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 00532c0a8e..44e0b20958 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -38,7 +38,6 @@ extension AppSettings { privacyLinkPreviewsGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } - if let val = privacyChatListOpenLinks { privacyChatListOpenLinksDefault.set(val) } if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } @@ -78,7 +77,6 @@ extension AppSettings { c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) - c.privacyChatListOpenLinks = privacyChatListOpenLinksDefault.get() c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 4c61d592ac..02dec5a618 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -367,13 +367,13 @@ struct ChatThemePreview: View { let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview")) let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)) HStack { - ChatItemView(chat: Chat.sampleData, chatItem: alice) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: alice, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) .modifier(ChatItemClipped(alice, tailVisible: true)) Spacer() } HStack { Spacer() - ChatItemView(chat: Chat.sampleData, chatItem: bob) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) .modifier(ChatItemClipped(bob, tailVisible: true)) .frame(alignment: .trailing) } diff --git a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift index cf9cada592..71c284e9ab 100644 --- a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift +++ b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift @@ -19,7 +19,7 @@ struct MarkdownHelp: View { mdFormat("_italic_", Text("italic").italic()) mdFormat("~strike~", Text("strike").strikethrough()) mdFormat("`a + b`", Text("`a + b`").font(.body.monospaced())) - mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")")) + mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(verbatim: " (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(verbatim: ")")) ( mdFormat("#secret#", Text("secret") .foregroundColor(.clear) @@ -39,7 +39,7 @@ private func mdFormat(_ format: LocalizedStringKey, _ example: Text) -> some Vie } private func color(_ s: String, _ c: Color) -> Text { - Text(s).foregroundColor(c) + Text(", ") + Text(s).foregroundColor(c) + Text(verbatim: ", ") } struct MarkdownHelp_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 754ca3cf6b..fa698f8b7c 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -208,6 +208,19 @@ struct AdvancedNetworkSettings: View { } } + Section { + Picker("Use web port", selection: $netCfg.smpWebPortServers) { + ForEach(SMPWebPortServers.allCases, id: \.self) { Text($0.text) } + } + .frame(height: 36) + } header: { + Text("TCP port for messaging") + } footer: { + netCfg.smpWebPortServers == .preset + ? Text("Use TCP port 443 for preset servers only.") + : Text("Use TCP port \(netCfg.smpWebPortServers == .all ? "443" : "5223") when no port is specified.") + } + Section("TCP connection") { timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) @@ -360,8 +373,8 @@ struct AdvancedNetworkSettings: View { let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.") return switch mode { case .user: userMode - case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.") - case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.") + case .session: userMode + textNewLine + Text("New SOCKS credentials will be used every time you start the app.") + case .server: userMode + textNewLine + Text("New SOCKS credentials will be used for each server.") case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.") } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 16aa98bc5f..6f4710396a 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -238,22 +238,23 @@ struct UsageConditionsView: View { var body: some View { VStack(alignment: .leading, spacing: 20) { - HStack { - Text("Conditions of use").font(.largeTitle).bold() - Spacer() - conditionsLinkButton() - } - .padding(.top) - .padding(.top) - switch ChatModel.shared.conditions.conditionsAction { case .none: + regularConditionsHeader() + .padding(.top) + .padding(.top) ConditionsTextView() .padding(.bottom) .padding(.bottom) case let .review(operators, deadline, _): + HStack { + Text("Updated conditions").font(.largeTitle).bold() + } + .padding(.top) + .padding(.top) + Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") ConditionsTextView() VStack(spacing: 8) { @@ -265,6 +266,10 @@ struct UsageConditionsView: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 32) + conditionsDiffButton(.footnote) + } else { + conditionsDiffButton() + .padding(.top) } } .padding(.bottom) @@ -272,6 +277,9 @@ struct UsageConditionsView: View { case let .accepted(operators): + regularConditionsHeader() + .padding(.top) + .padding(.top) Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") ConditionsTextView() .padding(.bottom) @@ -312,6 +320,43 @@ struct UsageConditionsView: View { } } } + + @ViewBuilder private func conditionsDiffButton(_ font: Font? = nil) -> some View { + let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit + if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") { + Link(destination: commitUrl) { + HStack { + Text("Open changes") + Image(systemName: "arrow.up.right.circle") + } + .font(font) + } + } + } +} + +private func regularConditionsHeader() -> some View { + HStack { + Text("Conditions of use").font(.largeTitle).bold() + Spacer() + conditionsLinkButton() + } +} + +struct SimpleConditionsView: View { + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + regularConditionsHeader() + .padding(.top) + .padding(.top) + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal, 25) + .frame(maxHeight: .infinity) + } } func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index 17a0ffdd1c..c8cb2349e7 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -65,7 +65,7 @@ struct NewServerView: View { useServerSection(valid) if valid { Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) { - MutableQRCode(uri: $serverToEdit.server) + MutableQRCode(uri: $serverToEdit.server, small: true) .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index 24da6a94a8..afbccc109c 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -38,9 +38,9 @@ struct OperatorView: View { .allowsHitTesting(!testing) } - @ViewBuilder private func operatorView() -> some View { + private func operatorView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) - VStack { + return VStack { List { Section { infoViewLink() @@ -500,14 +500,14 @@ struct SingleOperatorUsageConditionsView: View { } } - @ViewBuilder private func acceptConditionsButton() -> some View { + private func acceptConditionsButton() -> some View { let operatorIds = ChatModel.shared.conditions.serverOperators .filter { $0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator ($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted } .map { $0.operatorId } - Button { + return Button { acceptForOperators(operatorIds, operatorIndex) } label: { Text("Accept conditions") diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 13d01874ed..97bfd360cb 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -110,7 +110,7 @@ struct ProtocolServerView: View { useServerSection(valid) if valid { Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) { - MutableQRCode(uri: $serverToEdit.server) + MutableQRCode(uri: $serverToEdit.server, small: true) .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index ed3c5c773c..b9737914ec 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -38,9 +38,9 @@ struct YourServersView: View { .allowsHitTesting(!testing) } - @ViewBuilder private func yourServersView() -> some View { + private func yourServersView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) - List { + return List { if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { ForEach($userServers[operatorIndex].smpServers) { srv in diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index 4e7f826f4f..c4d0588987 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -13,7 +13,7 @@ struct NotificationsView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode - @State private var showAlert: NotificationAlert? + @State private var ntfAlert: NotificationAlert? @State private var legacyDatabase = dbContainerGroupDefault.get() == .documents @State private var testing = false @State private var testedSuccess: Bool? = nil @@ -25,7 +25,7 @@ struct NotificationsView: View { ProgressView().scaleEffect(2) } } - .alert(item: $showAlert) { alert in + .alert(item: $ntfAlert) { alert in if let token = m.deviceToken { return notificationAlert(alert, token) } else { @@ -41,7 +41,7 @@ struct NotificationsView: View { List { Section { SelectionListView(list: NotificationsMode.values, selection: $notificationMode) { mode in - showAlert = .setMode(mode: mode) + ntfAlert = .setMode(mode: mode) } } footer: { VStack(alignment: .leading) { @@ -95,7 +95,7 @@ struct NotificationsView: View { if let server = m.notificationServer { smpServers("Push server", [server], theme.colors.secondary) - testServerButton(server) + testTokenButton(server) } } header: { Text("Push notifications") @@ -163,7 +163,7 @@ struct NotificationsView: View { await MainActor.run { let err = responseError(error) logger.error("apiDeleteToken error: \(err)") - showAlert = .error(title: "Error deleting token", error: err) + ntfAlert = .error(title: "Error deleting token", error: err) } } default: @@ -181,19 +181,19 @@ struct NotificationsView: View { await MainActor.run { let err = responseError(error) logger.error("apiRegisterToken error: \(err)") - showAlert = .error(title: "Error enabling notifications", error: err) + ntfAlert = .error(title: "Error enabling notifications", error: err) } } } } } - private func testServerButton(_ server: String) -> some View { + private func testTokenButton(_ server: String) -> some View { HStack { - Button("Test server") { + Button("Test notifications") { testing = true Task { - await testServer(server) + await testServerAndToken(server) await MainActor.run { testing = false } } } @@ -215,22 +215,61 @@ struct NotificationsView: View { } } - private func testServer(_ server: String) async { + private func testServerAndToken(_ server: String) async { do { let r = try await testProtoServer(server: server) switch r { case .success: - await MainActor.run { - testedSuccess = true + if let token = m.deviceToken { + do { + let status = try await apiCheckToken(token: token) + await MainActor.run { + m.tokenStatus = status + testedSuccess = status.workingToken + if status.workingToken { + showAlert( + NSLocalizedString("Notifications status", comment: "alert title"), + message: tokenStatusInfo(status, register: false) + ) + } else { + showAlert( + title: NSLocalizedString("Notifications error", comment: "alert title"), + message: tokenStatusInfo(status, register: true), + buttonTitle: "Register", + buttonAction: { + reRegisterToken(token: token) + testedSuccess = nil + }, + cancelButton: true + ) + } + } + } catch let error { + await MainActor.run { + let err = responseError(error) + logger.error("apiCheckToken \(err)") + ntfAlert = .error(title: "Error checking token status", error: err) + } + } + } else { + await MainActor.run { + showAlert( + NSLocalizedString("No token!", comment: "alert title") + ) + } } case let .failure(f): await MainActor.run { - showAlert = .testFailure(testFailure: f) + ntfAlert = .testFailure(testFailure: f) testedSuccess = false } } } catch let error { - logger.error("testServerConnection \(responseError(error))") + await MainActor.run { + let err = responseError(error) + logger.error("testServerConnection \(err)") + ntfAlert = .error(title: "Error testing server connection", error: err) + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 0b9d1ef76c..eba7f8066a 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -14,12 +14,13 @@ struct PrivacySettings: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true - @State private var chatListOpenLinks = privacyChatListOpenLinksDefault.get() @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @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() @@ -75,17 +76,6 @@ struct PrivacySettings: View { privacyLinkPreviewsGroupDefault.set(linkPreviews) } } - settingsRow("arrow.up.right.circle", color: theme.colors.secondary) { - Picker("Open links from chat list", selection: $chatListOpenLinks) { - ForEach(PrivacyChatListOpenLinksMode.allCases) { mode in - Text(mode.text) - } - } - } - .frame(height: 36) - .onChange(of: chatListOpenLinks) { mode in - privacyChatListOpenLinksDefault.set(mode) - } settingsRow("message", color: theme.colors.secondary) { Toggle("Show last messages", isOn: $showChatPreviews) } @@ -111,6 +101,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 138c3689f5..50a012f4f8 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -29,10 +29,10 @@ let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group -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" @@ -58,6 +58,7 @@ let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" +let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" let DEFAULT_MIGRATION_TO_STAGE = "migrationToStage" @@ -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, @@ -116,6 +118,7 @@ let appDefaults: [String: Any] = [ DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, + DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, @@ -143,6 +146,7 @@ let hintDefaults = [ DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, DEFAULT_SHOW_MUTE_PROFILE_ALERT, + DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE, DEFAULT_SHOW_DELETE_CONTACT_NOTICE ] @@ -183,8 +187,6 @@ let connectViaLinkTabDefault = EnumDefault(defaults: UserDefa let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) -let privacyChatListOpenLinksDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS, withDefault: PrivacyChatListOpenLinksMode.ask) - let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET) @@ -196,6 +198,8 @@ let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.sta let showDeleteConversationNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE) let showDeleteContactNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONTACT_NOTICE) +let showReportsInSupportChatAlertDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT) + /// after importing new database, this flag will be set and unset only after importing app settings in `initializeChat` */ let shouldImportAppSettingsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_IMPORT_APP_SETTINGS) let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME) @@ -281,159 +285,159 @@ struct SettingsView: View { } } - @ViewBuilder func settingsView() -> some View { - let user = chatModel.currentUser - List { - Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { - NavigationLink { - NotificationsView() - .navigationTitle("Notifications") - .modifier(ThemedBackground(grouped: true)) - } label: { - HStack { - notificationsIcon() - Text("Notifications") - } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - NetworkAndServers() - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - CallSettings() - .navigationTitle("Your calls") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - PrivacySettings() - .navigationTitle("Your privacy") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") } - } - .disabled(chatModel.chatRunning != true) - - if UIApplication.shared.supportsAlternateIcons { - NavigationLink { - AppearanceSettings() - .navigationTitle("Appearance") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("sun.max", color: theme.colors.secondary) { Text("Appearance") } - } - .disabled(chatModel.chatRunning != true) + func settingsView() -> some View { + List { + let user = chatModel.currentUser + Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { + NavigationLink { + NotificationsView() + .navigationTitle("Notifications") + .modifier(ThemedBackground(grouped: true)) + } label: { + HStack { + notificationsIcon() + Text("Notifications") } } + .disabled(chatModel.chatRunning != true) - Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) { - chatDatabaseRow() - NavigationLink { - MigrateFromDevice(showProgressOnSettings: $showProgress) - .toolbar { - // Redaction broken for `.navigationTitle` - using a toolbar item instead. - ToolbarItem(placement: .principal) { - Text("Migrate device").font(.headline) - } - } - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - } label: { - settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") } - } + NavigationLink { + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } } - - Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { - if let user = user { - NavigationLink { - ChatHelp(dismissSettingsSheet: dismiss) - .navigationTitle("Welcome \(user.displayName)!") - .modifier(ThemedBackground()) - .frame(maxHeight: .infinity, alignment: .top) - } label: { - settingsRow("questionmark", color: theme.colors.secondary) { Text("How to use it") } - } - } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + CallSettings() + .navigationTitle("Your calls") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + PrivacySettings() + .navigationTitle("Your privacy") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") } + } + .disabled(chatModel.chatRunning != true) + + if UIApplication.shared.supportsAlternateIcons { NavigationLink { - WhatsNewView(viaSettings: true, updatedConditions: false) - .modifier(ThemedBackground()) - .navigationBarTitleDisplayMode(.inline) + AppearanceSettings() + .navigationTitle("Appearance") + .modifier(ThemedBackground(grouped: true)) } label: { - settingsRow("plus", color: theme.colors.secondary) { Text("What's new") } + settingsRow("sun.max", color: theme.colors.secondary) { Text("Appearance") } } + .disabled(chatModel.chatRunning != true) + } + } + + Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) { + chatDatabaseRow() + NavigationLink { + MigrateFromDevice(showProgressOnSettings: $showProgress) + .toolbar { + // Redaction broken for `.navigationTitle` - using a toolbar item instead. + ToolbarItem(placement: .principal) { + Text("Migrate device").font(.headline) + } + } + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") } + } + } + + Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { + if let user = user { NavigationLink { - SimpleXInfo(onboarding: false) - .navigationBarTitle("", displayMode: .inline) + ChatHelp(dismissSettingsSheet: dismiss) + .navigationTitle("Welcome \(user.displayName)!") .modifier(ThemedBackground()) .frame(maxHeight: .infinity, alignment: .top) } label: { - settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") } + settingsRow("questionmark", color: theme.colors.secondary) { Text("How to use it") } } - settingsRow("number", color: theme.colors.secondary) { - Button("Send questions and ideas") { - dismiss() - DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) - } + } + NavigationLink { + WhatsNewView(viaSettings: true, updatedConditions: false) + .modifier(ThemedBackground()) + .navigationBarTitleDisplayMode(.inline) + } label: { + settingsRow("plus", color: theme.colors.secondary) { Text("What's new") } + } + NavigationLink { + SimpleXInfo(onboarding: false) + .navigationBarTitle("", displayMode: .inline) + .modifier(ThemedBackground()) + .frame(maxHeight: .infinity, alignment: .top) + } label: { + settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") } + } + settingsRow("number", color: theme.colors.secondary) { + Button("Send questions and ideas") { + dismiss() + DispatchQueue.main.async { + UIApplication.shared.open(simplexTeamURL) } } - .disabled(chatModel.chatRunning != true) - settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") } } + .disabled(chatModel.chatRunning != true) + settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") } + } - Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) { - settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") } - settingsRow("star", color: theme.colors.secondary) { - Button("Rate the app") { - if let scene = sceneDelegate.windowScene { - SKStoreReviewController.requestReview(in: scene) - } + Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) { + settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") } + settingsRow("star", color: theme.colors.secondary) { + Button("Rate the app") { + if let scene = sceneDelegate.windowScene { + SKStoreReviewController.requestReview(in: scene) } } - ZStack(alignment: .leading) { - Image(colorScheme == .dark ? "github_light" : "github") - .resizable() - .frame(width: 24, height: 24) - .opacity(0.5) - .colorMultiply(theme.colors.secondary) - Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)") - .padding(.leading, indent) - } } + ZStack(alignment: .leading) { + Image(colorScheme == .dark ? "github_light" : "github") + .resizable() + .frame(width: 24, height: 24) + .opacity(0.5) + .colorMultiply(theme.colors.secondary) + Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)") + .padding(.leading, indent) + } + } - Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) { - NavigationLink { - DeveloperView() - .navigationTitle("Developer tools") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") } - } - NavigationLink { - VersionView() - .navigationBarTitle("App version") - .modifier(ThemedBackground()) - } label: { - Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") - } + Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) { + NavigationLink { + DeveloperView() + .navigationTitle("Developer tools") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") } + } + NavigationLink { + VersionView() + .navigationBarTitle("App version") + .modifier(ThemedBackground()) + } label: { + Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") } } - .navigationTitle("Your settings") - .modifier(ThemedBackground(grouped: true)) - .onDisappear { - chatModel.showingTerminal = false - chatModel.terminalItems = [] - } + } + .navigationTitle("Your settings") + .modifier(ThemedBackground(grouped: true)) + .onDisappear { + chatModel.showingTerminal = false + chatModel.terminalItems = [] + } } private func chatDatabaseRow() -> some View { @@ -477,7 +481,11 @@ struct SettingsView: View { case .registered: icon = "bolt.fill" color = theme.colors.secondary - case .invalid: + case .invalid: fallthrough + case .invalidBad: fallthrough + case .invalidTopic: fallthrough + case .invalidExpired: fallthrough + case .invalidUnregistered: icon = "bolt.slash" color = theme.colors.secondary case .confirmed: @@ -524,7 +532,7 @@ struct ProfilePreview: View { func profileName(_ profileOf: NamedChat) -> Text { var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2) if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { - t = t + Text(" (" + profileOf.fullName + ")") + t = t + Text(verbatim: " (" + profileOf.fullName + ")") // .font(.callout) } return t diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift index 2cf63692a7..094c1cb3d6 100644 --- a/apps/ios/Shared/Views/UserSettings/StorageView.swift +++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift @@ -33,7 +33,7 @@ struct StorageView: View { private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { Text(name).font(.headline) ForEach(Array(contents), id: \.key) { (key, value) in - Text(key).bold() + Text(" ") + Text("\(ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))") + Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 7965215b49..f374995f78 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) @@ -152,9 +153,11 @@ struct UserAddressView: View { } } addressSettingsButton(userAddress) + if (userAddress.connLinkContact.connShortLink == nil && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)) { + addShortLinkButton() + } } 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 +196,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 } @@ -208,6 +212,32 @@ struct UserAddressView: View { } } + private func addShortLinkButton() -> some View { + Button { + addShortLink() + } label: { + Label("Add short link", systemImage: "plus") + } + } + + private func addShortLink() { + progressIndicator = true + Task { + do { + let userAddress = try await apiAddMyAddressShortLink() + await MainActor.run { + chatModel.userAddress = userAddress + } + await MainActor.run { progressIndicator = false } + } catch let error { + logger.error("apiAddMyAddressShortLink: \(responseError(error))") + let a = getErrorAlert(error, "Error creating address") + alert = .error(title: a.title, error: a.message) + await MainActor.run { progressIndicator = false } + } + } + } + private func createOneTimeLinkButton() -> some View { NavigationLink { NewChatView(selection: .invite) @@ -231,7 +261,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 +324,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 +594,7 @@ private func saveAAS(_ aas: Binding, _ savedAAS: Binding Void - ) -> some View { - Image(systemName: systemName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 12) - .foregroundColor(theme.colors.primary) - .padding(6) - .frame(width: 36, height: 36, alignment: .center) - .background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5)) - .clipShape(Circle()) - .contentShape(Circle()) - .padding([.trailing, edge], -12) - .onTapGesture(perform: action) - } - private func showFullName(_ user: User) -> Bool { user.profile.fullName != "" && user.profile.fullName != user.profile.displayName } @@ -190,8 +150,54 @@ struct UserProfile: View { } } -func profileImageView(_ imageStr: String?) -> some View { - ProfileImage(imageStr: imageStr, size: 192) +struct EditProfileImage: View { + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner + @Binding var profileImage: String? + @Binding var showChooseSource: Bool + + var body: some View { + Group { + if profileImage != nil { + ZStack(alignment: .bottomTrailing) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profileImage, size: 160) + .onTapGesture { showChooseSource = true } + overlayButton("multiply", edge: .top) { profileImage = nil } + } + overlayButton("camera", edge: .bottom) { showChooseSource = true } + } + } else { + ZStack(alignment: .center) { + ProfileImage(imageStr: profileImage, size: 160) + editImageButton { showChooseSource = true } + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .contentShape(Rectangle()) + } + + private func overlayButton( + _ systemName: String, + edge: Edge.Set, + action: @escaping () -> Void + ) -> some View { + Image(systemName: systemName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 12) + .foregroundColor(theme.colors.primary) + .padding(6) + .frame(width: 36, height: 36, alignment: .center) + .background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5)) + .clipShape(Circle()) + .contentShape(Circle()) + .padding([.trailing, edge], -12) + .onTapGesture(perform: action) + } } func editImageButton(action: @escaping () -> Void) -> some View { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 781ea4bc34..ddfe59e719 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -221,11 +221,11 @@ struct UserProfilesView: View { !user.hidden ? nil : trimmedSearchTextOrPassword } - @ViewBuilder private func profileActionView(_ action: UserProfileAction) -> some View { + private func profileActionView(_ action: UserProfileAction) -> some View { let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces) let passwordField = PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid) let actionEnabled: (User) -> Bool = { user in actionPassword != "" && passwordValid && correctPassword(user, actionPassword) } - List { + return List { switch action { case let .deleteUser(user, delSMPQueues): actionHeader("Delete profile", user) @@ -350,7 +350,7 @@ struct UserProfilesView: View { Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground) } else { if userInfo.unreadCount > 0 { - UnreadBadge(userInfo: userInfo) + userUnreadBadge(userInfo, theme: theme) } if user.hidden { Image(systemName: "lock").foregroundColor(theme.colors.secondary) diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index c78a7cb941..2ec32def0a 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -9,6 +9,10 @@ applinks: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 Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index ef91bb30fd..e965e5a1a5 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -49,7 +49,7 @@ %@ - %@ + %@ No comment provided by engineer. @@ -379,292 +379,356 @@ أضف إلى جهاز آخر No comment provided by engineer. - + Admins can create the links to join groups. + يمكن للمُدراء إنشاء روابط للانضمام إلى المجموعات. No comment provided by engineer. - + Advanced network settings + إعدادات الشبكة المتقدمة No comment provided by engineer. - + All chats and messages will be deleted - this cannot be undone! + سيتم حذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا! No comment provided by engineer. - + All group members will remain connected. + سيبقى جميع أعضاء المجموعة على اتصال. No comment provided by engineer. - + All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. + سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك. No comment provided by engineer. All your contacts will remain connected No comment provided by engineer. - + Allow + سماح No comment provided by engineer. - + Allow disappearing messages only if your contact allows it to you. + السماح بالرسائل المختفية فقط إذا سمحت لك جهة الاتصال بذلك. No comment provided by engineer. Allow irreversible message deletion only if your contact allows it to you. No comment provided by engineer. - + Allow sending direct messages to members. + السماح بإرسال رسائل مباشرة إلى الأعضاء. No comment provided by engineer. - + Allow sending disappearing messages. + السماح بإرسال الرسائل التي تختفي. No comment provided by engineer. Allow to irreversibly delete sent messages. No comment provided by engineer. - + Allow to send voice messages. + السماح بإرسال رسائل صوتية. No comment provided by engineer. - + Allow voice messages only if your contact allows them. + اسمح بالرسائل الصوتية فقط إذا سمحت جهة اتصالك بذلك. No comment provided by engineer. - + Allow voice messages? + السماح بالرسائل الصوتية؟ No comment provided by engineer. Allow your contacts to irreversibly delete sent messages. No comment provided by engineer. - + Allow your contacts to send disappearing messages. + السماح لجهات اتصالك بإرسال رسائل تختفي. No comment provided by engineer. - + Allow your contacts to send voice messages. + اسمح لجهات اتصالك بإرسال رسائل صوتية. No comment provided by engineer. - + Already connected? + متصل بالفعل؟ No comment provided by engineer. - + Answer call + أجب الاتصال No comment provided by engineer. - + App build: %@ + إصدار التطبيق: %@ No comment provided by engineer. - + App icon + رمز التطبيق No comment provided by engineer. - + App version + نسخة التطبيق No comment provided by engineer. - + App version: v%@ + نسخة التطبيق: v%@ No comment provided by engineer. - + Appearance + المظهر No comment provided by engineer. - + Attach + إرفاق No comment provided by engineer. - + Audio & video calls + مكالمات الصوت والفيديو No comment provided by engineer. - + Authentication failed + فشلت المصادقة No comment provided by engineer. - + Authentication unavailable + المصادقة غير متاحة No comment provided by engineer. - + Auto-accept contact requests + قبول طلبات الاتصال تلقائيًا No comment provided by engineer. - + Auto-accept images + قبول تلقائي للصور No comment provided by engineer. Automatically No comment provided by engineer. - + Back + رجوع No comment provided by engineer. Both you and your contact can irreversibly delete sent messages. No comment provided by engineer. - + Both you and your contact can send disappearing messages. + يمكنك أنت وجهة اتصالك إرسال رسائل تختفي. No comment provided by engineer. - + Both you and your contact can send voice messages. + يمكنك أنت وجهة اتصالك إرسال رسائل صوتية. No comment provided by engineer. - + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + حسب ملف تعريف الدردشة (افتراضي) أو [حسب الاتصال] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - + Call already ended! + انتهت المكالمة بالفعل! No comment provided by engineer. - + Calls + المكالمات No comment provided by engineer. - + Can't invite contact! + لا يمكن دعوة جهة اتصال! No comment provided by engineer. - + Can't invite contacts! + لا يمكن دعوة جهات الاتصال! No comment provided by engineer. - + Cancel + إلغاء No comment provided by engineer. - + Cannot access keychain to save database password + لا يمكن الوصول إلى سلسلة المفاتيح لحفظ كلمة مرور قاعدة البيانات No comment provided by engineer. - + Cannot receive file + لا يمكن استلام الملف No comment provided by engineer. - + Change + تغير No comment provided by engineer. - + Change database passphrase? + تغيير عبارة مرور قاعدة البيانات؟ No comment provided by engineer. - + Change member role? + تغيير دور العضو؟ No comment provided by engineer. - + Change receiving address + تغيير عنوان الاستلام No comment provided by engineer. - + Change receiving address? + تغيير عنوان الاستلام؟ No comment provided by engineer. - + Change role + تغيير الدور No comment provided by engineer. Chat archive No comment provided by engineer. - + Chat console + وحدة تحكم الدردشة No comment provided by engineer. - + Chat database + قاعدة بيانات الدردشة No comment provided by engineer. - + Chat database deleted + حُذفت قاعدة بيانات الدردشة No comment provided by engineer. - + Chat database imported + استُوردت قاعدة بيانات الدردشة No comment provided by engineer. - + Chat is running + الدردشة قيد التشغيل No comment provided by engineer. - + Chat is stopped + توقفت الدردشة No comment provided by engineer. - + Chat preferences + تفضيلات الدردشة No comment provided by engineer. - + Chats + الدردشات No comment provided by engineer. - + Check server address and try again. + تحقق من عنوان الخادم وحاول مرة أخرى. No comment provided by engineer. - + Choose file + اختر الملف No comment provided by engineer. - + Choose from library + اختر من المكتبة No comment provided by engineer. - + Clear + مسح No comment provided by engineer. - + Clear conversation + مسح الدردشة No comment provided by engineer. - + Clear conversation? + مسح الدردشة؟ No comment provided by engineer. - + Clear verification + امسح التحقُّق No comment provided by engineer. Colors No comment provided by engineer. - + Compare security codes with your contacts. + قارن رموز الأمان مع جهات اتصالك. No comment provided by engineer. - + Configure ICE servers + ضبط خوادم ICE No comment provided by engineer. - + Confirm + تأكيد No comment provided by engineer. - + Confirm new passphrase… + تأكيد عبارة المرور الجديدة… No comment provided by engineer. - + Connect + اتصل server test step @@ -675,8 +739,9 @@ Connect via group link? No comment provided by engineer. - + Connect via link + تواصل عبر الرابط No comment provided by engineer. @@ -691,224 +756,273 @@ Connect via relay No comment provided by engineer. - + Connecting to server… + جارِ الاتصال بالخادم… No comment provided by engineer. - + Connecting to server… (error: %@) + الاتصال بالخادم... (الخطأ: %@) No comment provided by engineer. - + Connection + الاتصال No comment provided by engineer. - + Connection error + خطأ في الإتصال No comment provided by engineer. - + Connection error (AUTH) + خطأ في الإتصال (المصادقة) No comment provided by engineer. Connection request No comment provided by engineer. - + Connection request sent! + أرسلت طلب الاتصال! No comment provided by engineer. - + Connection timeout + انتهت مهلة الاتصال No comment provided by engineer. - + Contact allows + تسمح جهة الاتصال No comment provided by engineer. - + Contact already exists + جهة الاتصال موجودة بالفعل No comment provided by engineer. Contact and all messages will be deleted - this cannot be undone! No comment provided by engineer. - + Contact hidden: + جهة الاتصال مخفية: notification - + Contact is connected + تم الاتصال notification Contact is not connected yet! No comment provided by engineer. - + Contact name + اسم جهة الاتصال No comment provided by engineer. - + Contact preferences + تفضيلات جهة الاتصال No comment provided by engineer. Contact requests No comment provided by engineer. - + Contacts can mark messages for deletion; you will be able to view them. + يمكن لجهات الاتصال تحديد الرسائل لحذفها؛ ستتمكن من مشاهدتها. No comment provided by engineer. - + Copy + نسخ chat item action Core built at: %@ No comment provided by engineer. - + Core version: v%@ + الإصدار الأساسي: v%@ No comment provided by engineer. - + Create + إنشاء No comment provided by engineer. Create address No comment provided by engineer. - + Create group link + إنشاء رابط المجموعة No comment provided by engineer. - + Create link + إنشاء رابط No comment provided by engineer. Create one-time invitation link No comment provided by engineer. - + Create queue + إنشاء قائمة انتظار server test step - + Create secret group + إنشاء مجموعة سرية No comment provided by engineer. - + Create your profile + أنشئ ملف تعريفك No comment provided by engineer. Created on %@ No comment provided by engineer. - + Current passphrase… + عبارة المرور الحالية… No comment provided by engineer. - + Currently maximum supported file size is %@. + الحد الأقصى لحجم الملف المدعوم حاليًا هو %@. No comment provided by engineer. - + Dark + داكن No comment provided by engineer. - + Database ID + معرّف قاعدة البيانات No comment provided by engineer. - + Database encrypted! + قاعدة البيانات مُعمّاة! No comment provided by engineer. - + Database encryption passphrase will be updated and stored in the keychain. + سيتم تحديث عبارة المرور الخاصة بتشفير قاعدة البيانات وتخزينها في سلسلة المفاتيح. + No comment provided by engineer. - + Database encryption passphrase will be updated. + سيتم تحديث عبارة مرور تعمية قاعدة البيانات. + No comment provided by engineer. - + Database error + خطأ في قاعدة البيانات No comment provided by engineer. - + Database is encrypted using a random passphrase, you can change it. + قاعدة البيانات مُعمّاة باستخدام عبارة مرور عشوائية، يمكنك تغييرها. No comment provided by engineer. - + Database is encrypted using a random passphrase. Please change it before exporting. + قاعدة البيانات مُعمّاة باستخدام عبارة مرور عشوائية. يُرجى تغييره قبل التصدير. No comment provided by engineer. - + Database passphrase + عبارة مرور قاعدة البيانات No comment provided by engineer. - + Database passphrase & export + عبارة مرور قاعدة البيانات وتصديرها No comment provided by engineer. - + Database passphrase is different from saved in the keychain. + عبارة المرور الخاصة بقاعدة البيانات مختلفة عن تلك المحفوظة في سلسلة المفاتيح. No comment provided by engineer. - + Database passphrase is required to open chat. + عبارة مرور قاعدة البيانات مطلوبة لفتح الدردشة. No comment provided by engineer. - + Database will be encrypted and the passphrase stored in the keychain. + سيتم تشفير قاعدة البيانات وتخزين عبارة المرور في سلسلة المفاتيح. + No comment provided by engineer. - + Database will be encrypted. + سيتم تعمية قاعدة البيانات. + No comment provided by engineer. - + Database will be migrated when the app restarts + سيتم نقل قاعدة البيانات عند إعادة تشغيل التطبيق No comment provided by engineer. - + Decentralized + لامركزي No comment provided by engineer. - + Delete + حذف chat item action Delete Contact No comment provided by engineer. - + Delete address + حذف العنوان No comment provided by engineer. - + Delete address? + حذف العنوان؟ No comment provided by engineer. - + Delete after + حذف بعد No comment provided by engineer. - + Delete all files + حذف جميع الملفات No comment provided by engineer. @@ -919,152 +1033,188 @@ Delete chat archive? No comment provided by engineer. - + Delete chat profile? + حذف ملف تعريف الدردشة؟ No comment provided by engineer. - + Delete connection + حذف الاتصال No comment provided by engineer. - + Delete contact + حذف جهة الاتصال No comment provided by engineer. - + Delete contact? + حذف جهة الاتصال؟ No comment provided by engineer. - + Delete database + حذف قاعدة البيانات No comment provided by engineer. - + Delete files and media? + حذف الملفات والوسائط؟ No comment provided by engineer. - + Delete files for all chat profiles + حذف الملفات لجميع ملفات تعريف الدردشة No comment provided by engineer. - + Delete for everyone + حذف للجميع chat feature - + Delete for me + حذف بالنسبة لي No comment provided by engineer. - + Delete group + حذف المجموعة No comment provided by engineer. - + Delete group? + حذف المجموعة؟ No comment provided by engineer. - + Delete invitation + حذف الدعوة No comment provided by engineer. - + Delete link + حذف الرابط No comment provided by engineer. - + Delete link? + حذف الرابط؟ No comment provided by engineer. - + Delete message? + حذف الرسالة؟ No comment provided by engineer. - + Delete messages + حذف الرسائل No comment provided by engineer. - + Delete messages after + حذف الرسائل بعد No comment provided by engineer. - + Delete old database + حذف قاعدة البيانات القديمة No comment provided by engineer. - + Delete old database? + حذف قاعدة البيانات القديمة؟ No comment provided by engineer. Delete pending connection No comment provided by engineer. - + Delete pending connection? + حذف الاتصال قيد الانتظار؟ No comment provided by engineer. - + Delete queue + حذف قائمة الانتظار server test step - + Delete user profile? + حذف ملف تعريف المستخدم؟ No comment provided by engineer. - + Description + الوصف No comment provided by engineer. - + Develop + يطور No comment provided by engineer. - + Developer tools + أدوات المطور No comment provided by engineer. - + Device + الجهاز No comment provided by engineer. - + Device authentication is disabled. Turning off SimpleX Lock. + استيثاق الجهاز مُعطَّل. جارِ إيقاف تشغيل قفل SimpleX. No comment provided by engineer. - + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + مصادقة الجهاز غير مفعّلة. يمكنك تشغيل قفل SimpleX عبر الإعدادات، بمجرد تفعيل مصادقة الجهاز. No comment provided by engineer. - + Different names, avatars and transport isolation. + أسماء مختلفة، صور الأفاتار وعزل النقل. No comment provided by engineer. - + Direct messages + رسائل مباشرة chat feature - + Direct messages between members are prohibited. + الرسائل المباشرة بين الأعضاء ممنوعة. No comment provided by engineer. - + Disable SimpleX Lock + تعطيل قفل SimpleX authentication reason - + Disappearing messages + الرسائل المختفية chat feature - + Disappearing messages are prohibited in this chat. + يُحظر اختفاء الرسائل في هذه الدردشة. No comment provided by engineer. - + Disappearing messages are prohibited. + الرسائل المختفية ممنوعة. No comment provided by engineer. - + Disconnect + قطع الاتصال server test step @@ -1075,124 +1225,153 @@ Display name: No comment provided by engineer. - + Do NOT use SimpleX for emergency calls. + لا تستخدم SimpleX لإجراء مكالمات الطوارئ. No comment provided by engineer. - + Do it later + افعل ذلك لاحقا No comment provided by engineer. - + Duplicate display name! + اسم العرض مكرر! No comment provided by engineer. - + Edit + تحرير chat item action - + Edit group profile + حرّر ملف تعريف المجموعة No comment provided by engineer. - + Enable + تفعيل No comment provided by engineer. - + Enable SimpleX Lock + تفعيل قفل SimpleX authentication reason - + Enable TCP keep-alive + تفعيل أبقِ TCP على قيد الحياة No comment provided by engineer. - + Enable automatic message deletion? + تفعيل الحذف التلقائي للرسائل؟ No comment provided by engineer. - + Enable instant notifications? + تفعيل الإشعارات فورية؟ No comment provided by engineer. - + Enable notifications + تفعيل الإشعارات No comment provided by engineer. - + Enable periodic notifications? + تفعيل الإشعارات دورية؟ No comment provided by engineer. - + Encrypt + التشفير No comment provided by engineer. - + Encrypt database? + تشفير قاعدة البيانات؟ No comment provided by engineer. - + Encrypted database + قاعدة بيانات مشفرة No comment provided by engineer. - + Encrypted message or another event + رسالة مشفرة أو حدث آخر notification - + Encrypted message: database error + رسالة مشفرة: خطأ في قاعدة البيانات notification - + Encrypted message: keychain error + رسالة مشفرة: خطأ في سلسلة المفاتيح notification - + Encrypted message: no passphrase + الرسالة المشفرة: لا توجد عبارة مرور notification - + Encrypted message: unexpected error + رسالة مشفرة: خطأ غير متوقع notification - + Enter correct passphrase. + أدخل عبارة المرور الصحيحة. No comment provided by engineer. - + Enter passphrase… + أدخل عبارة المرور… No comment provided by engineer. - + Enter server manually + أدخل الخادم يدوياً No comment provided by engineer. - + Error + خطأ No comment provided by engineer. - + Error accepting contact request + خطأ في قبول طلب الاتصال No comment provided by engineer. Error accessing database file No comment provided by engineer. - + Error adding member(s) + خطأ في إضافة عضو (أعضاء) No comment provided by engineer. - + Error changing address + خطأ في تغيير العنوان No comment provided by engineer. - + Error changing role + خطأ في تغيير الدور المتغير No comment provided by engineer. - + Error changing setting + خطأ في تغيير الإعدادات No comment provided by engineer. @@ -1973,8 +2152,9 @@ We will be adding server redundancy to prevent lost messages. Open user profiles authentication reason - + Anybody can host servers. + يمكن لأي شخص استضافة الخوادم. No comment provided by engineer. @@ -2377,96 +2557,117 @@ We will be adding server redundancy to prevent lost messages. Sent messages will be deleted after set time. No comment provided by engineer. - + Server requires authorization to create queues, check password + يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور server test error - + Server test failed! + فشلت تجربة الخادم! No comment provided by engineer. - + Servers + الخوادم No comment provided by engineer. - + Set 1 day + تعيين يوم واحد No comment provided by engineer. - + Set contact name… + تعيين اسم جهة الاتصال… No comment provided by engineer. - + Set group preferences + عيّن تفضيلات المجموعة No comment provided by engineer. - + Set passphrase to export + عيّن عبارة المرور للتصدير No comment provided by engineer. - + Set timeouts for proxy/VPN + حدد مهلات للوسيط او شبكات افتراضية خاصة (Proxy/VPN timeouts) No comment provided by engineer. - + Settings + الإعدادات No comment provided by engineer. - + Share + مشاركة chat item action Share invitation link No comment provided by engineer. - + Share link + مشاركة الرابط No comment provided by engineer. Share one-time invitation link No comment provided by engineer. - + Show QR code + عرض رمز QR No comment provided by engineer. - + Show preview + عرض المعاينة No comment provided by engineer. - + SimpleX Chat security was audited by Trail of Bits. + تم تدقيق أمان SimpleX Chat بواسطة Trail of Bits. No comment provided by engineer. - + SimpleX Lock + قفل SimpleX No comment provided by engineer. - + SimpleX Lock turned on + تم تشغيل القفل SimpleX No comment provided by engineer. - + SimpleX contact address + عنوان جهة أتصال SimpleX simplex link type - + SimpleX encrypted message or connection event + حَدَثْ SimpleX لرسالة أو اتصال مشفر notification - + SimpleX group link + رابط مجموعة SimpleX simplex link type - + SimpleX links + روابط SimpleX No comment provided by engineer. - + SimpleX one-time invitation + دعوة SimpleX لمرة واحدة simplex link type @@ -2625,8 +2826,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. @@ -3526,72 +3727,87 @@ SimpleX servers cannot see your profile. secret No comment provided by engineer. - + starting… + يبدأ… No comment provided by engineer. - + strike + شطب No comment provided by engineer. this contact notification title - + unknown + غير معروف connection info - + updated group profile + حدثت ملف تعريف المجموعة rcv group event chat item v%@ (%@) No comment provided by engineer. - + via contact address link + عبر رابط عنوان الاتصال chat list item description - + via group link + عبر رابط المجموعة chat list item description - + via one-time link + عبر رابط لمرة واحدة chat list item description - + via relay + عبر المُرحل No comment provided by engineer. - + video call (not e2e encrypted) + مكالمة الفيديو ليست مُعمّاة بين الطريفين No comment provided by engineer. - + waiting for answer… + بانتظار الرد… No comment provided by engineer. - + waiting for confirmation… + في انتظار التأكيد… No comment provided by engineer. - + wants to connect to you! + يريد الاتصال بك! No comment provided by engineer. - + yes + نعم pref value - + you are invited to group + أنت مدعو إلى المجموعة No comment provided by engineer. - + you changed address + غيّرتَ العنوان chat item text @@ -3606,16 +3822,18 @@ SimpleX servers cannot see your profile. you changed role of %1$@ to %2$@ snd group event chat item - + you left + غادرت snd group event chat item you removed %@ snd group event chat item - + you shared one-time link + لقد شاركت رابط لمرة واحدة chat list item description @@ -3657,7 +3875,7 @@ SimpleX servers cannot see your profile. # %@ - # %@ + # %@ copied message info title, # <title> @@ -3877,6 +4095,1668 @@ SimpleX servers cannot see your profile. Active connections اتصالات نشطة + + Apply + طبّق + + + %@ server + %@ خادم + + + Accept conditions + اقبل الشروط + + + Share address + مشاركة العنوان + + + Already connecting! + جارٍ الاتصال بالفعل! + + + %d file(s) are still being downloaded. + %d الملف(ات) لا تزال قيد التنزيل. + + + %d file(s) failed to download. + %d الملف(ات) فشلت في التنزيل. + + + All app data is deleted. + حُذفت جميع بيانات التطبيق. + + + Allow irreversible message deletion only if your contact allows it to you. (24 hours) + السماح بحذف الرسائل بشكل لا رجوع فيه فقط إذا سمحت لك جهة الاتصال بذلك. (24 ساعة) + + + Share profile + شارك ملف التعريف + + + Always use relay + استخدم الموجه دائمًا + + + Address + عنوان + + + All data is erased when it is entered. + يتم مسح جميع البيانات عند إدخالها. + + + %d file(s) were deleted. + %d تم حذف الملف(ات). + + + %d file(s) were not downloaded. + %d لم يتم تنزيل الملف(ات). + + + %d messages not forwarded + %d الرسائل لم يتم تحويلها + + + %d seconds(s) + %d ثواني + + + **Scan / Paste link**: to connect via a link you received. + **امسح / ألصِق الرابط**: للاتصال عبر الرابط الذي تلقيته. + + + 1 year + سنة واحدة + + + 1-time link + رابط لمرة واحدة + + + 1-time link can be used *with one contact only* - share in person or via any messenger. + يمكن استعمال الرابط لمرة واحدة *مع جهة اتصال واحدة فقط* - شاركه شخصياً أو عبر أي تطبيق مراسلة. + + + Accent + لون تمييزي + + + Accepted conditions + الشروط المتفق عليها + + + All chats will be removed from the list (text), and the list deleted. + سيتم إزالة جميع الدردشات من القائمة (النص)، وحذف القائمة. + + + Allow message reactions. + السماح بردود الفعل على الرسائل. + + + Allow to irreversibly delete sent messages. (24 hours) + السماح بحذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) + + + Allow to send SimpleX links. + السماح بإرسال روابط SimpleX. + + + Already joining the group! + جارٍ انضمام بالفعل إلى المجموعة! + + + An empty chat profile with the provided name is created, and the app opens as usual. + يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. + + + Authentication cancelled + ألغيت المصادقة + + + Audio/video calls are prohibited. + مكالمات الصوت/الفيديو محظورة. + + + Better groups + مجموعات أفضل + + + Background + الخلفية + + + Better calls + مكالمات أفضل + + + Both you and your contact can irreversibly delete sent messages. (24 hours) + يمكنك أنت وجهة اتصالك حذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) + + + Block member for all? + حظر العضو للجميع؟ + + + Blur media + تمويه الوسائط + + + Server type + نوع الخادم + + + Server requires authorization to upload, check password + يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور + + + Server version is incompatible with network settings. + إصدار الخادم غير متوافق مع إعدادات الشبكة. + + + Share with contacts + مشاركة مع جهات الاتصال + + + Show: + عرض: + + + SimpleX Address + عنوان SimpleX + + + SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + توصلت SimpleX Chat وFlux إلى اتفاق لتضمين الخوادم التي تديرها Flux في التطبيق. + + + Allow calls? + السماح بالمكالمات؟ + + + App passcode is replaced with self-destruct passcode. + يتم استبدال رمز مرور التطبيق برمز مرور التدمير الذاتي. + + + SimpleX Lock mode + SimpleX وضع القفل + + + Audio and video calls + مكالمات الصوت والفيديو + + + App passcode + رمز مرور التطبيق + + + Bad message ID + معرّف رسالة سيئ + + + Server address is incompatible with network settings. + عنوان الخادم غير متوافق مع إعدادات الشبكة. + + + Servers statistics will be reset - this cannot be undone! + سيتم تصفير إحصائيات الخوادم - لا يمكن التراجع عن هذا! + + + Allow to send files and media. + السماح بإرسال الملفات والوسائط. + + + App encrypts new local files (except videos). + يُعمِّي الملفات المحلية الجديدة (باستثناء مقاطع الفيديو). + + + Better messages + رسائل أفضل + + + Set passcode + عيّن رمز المرور + + + Additional accent 2 + لون إضافي ثانوي 2 + + + Allow your contacts adding message reactions. + السماح لجهات اتصالك بإضافة ردود الفعل للرسالة. + + + Allow your contacts to call you. + السماح لجهات اتصالك بالاتصال بك. + + + Audio/video calls + مكالمات الصوت/الفيديو + + + Better notifications + إشعارات أفضل + + + Better user experience + تجربة مستخدم أفضل + + + Block + حظر + + + Black + أسود + + + Block member? + حظر العضو؟ + + + Blocked by admin + محظور من قبل المُدير + + + Blur for better privacy. + تمويه من أجل خصوصية أفضل. + + + Show → on messages sent via private routing. + عرض ← على الرسائل المرسلة عبر التوجيه الخاص. + + + Share from other apps. + المشاركة من التطبيقات الأخرى. + + + Share this 1-time invite link + شارك رابط الدعوة هذا لمرة واحدة + + + Set passphrase + عيّن عبارة المرور + + + Share address with contacts? + مشاركة العنوان مع جهات الاتصال؟ + + + Allow downgrade + السماح بالرجوع إلى إصدار سابق + + + Bad desktop address + عنوان سطح المكتب غير صالح + + + %1$@, %2$@ + %1$@, %2$@ + + + All profiles + جميع ملفات التعريف + + + Authentication is required before the call is connected, but you may miss calls. + يتطلب التوثيق قبل الاتصال بالمكالمة، ولكن قد تفوتك المكالمات. + + + Archiving database + جارِ أرشفة قاعدة البيانات + + + Settings were changed. + تم تغيير الإعدادات. + + + Better groups performance + أداء مجموعات أفضل + + + Better privacy and security + خصوصية وأمان أفضل + + + Better security ✅ + أمان أفضل ✅ + + + Block for all + حظر للجميع + + + Block group members + حظر أعضاء المجموعة + + + Block member + حظر العضو + + + Both you and your contact can add message reactions. + يمكنك أنت وجهة اتصالك إضافة ردود فعل الرسائل. + + + Both you and your contact can make calls. + يمكنك أنت وجهة الاتصال إجراء مكالمات. + + + Server + الخادم + + + Server operators + مُشغلي الخادم + + + Server version is incompatible with your app: %@. + إصدار الخادم غير متوافق مع التطبيق لديك: %@. + + + Servers info + معلومات الخوادم + + + Set chat name… + عيّن اسم الدردشة… + + + Shape profile images + شكّل الصور التعريفية + + + Share address publicly + شارك العنوان علناً + + + Show developer options + عرض خيارات المطور + + + SimpleX address + عنوان SimpleX + + + SimpleX address or 1-time link? + عنوان SimpleX أو رابط لمرة واحدة؟ + + + @'%@' + @'%@' + + + @%@ + @%@ + + + Active + نشط + + + Add friends + أضف أصدقاء + + + Add list + أضف القائمة + + + Address change will be aborted. Old receiving address will be used. + سيتم إحباط تغيير العنوان. سيتم استخدام عنوان الاستلام القديم. + + + All messages will be deleted - this cannot be undone! + سيتم حذف كافة الرسائل - لا يمكن التراجع عن هذا! + + + All reports will be archived for you. + سيتم أرشفة كافة البلاغات لك. + + + All your contacts will remain connected. + ستبقى جميع جهات اتصالك متصلة. + + + All your contacts will remain connected. Profile update will be sent to your contacts. + ستبقى جميع جهات اتصالك متصلة. سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. + + + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + جميع جهات الاتصال، المحادثات والملفات الخاصة بك سيتم تشفيرها بأمان ورفعها على شكل أجزاء إلى موجهات XFTP المُعدة. + + + Allow calls only if your contact allows them. + السماح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك. + + + Allow message reactions only if your contact allows them. + اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك. + + + Allow to report messsages to moderators. + السماح بالإبلاغ عن الرسائل إلى المشرفين. + + + Allow your contacts to irreversibly delete sent messages. (24 hours) + اسمح لجهات اتصالك بحذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) + + + Another reason + سبب آخر + + + App group: + مجموعة التطبيق: + + + Apply to + طبّق لِ + + + Archive + أرشف + + + Archive %lld reports? + أرشف تقارير %lld؟ + + + Archive all reports? + أرشفة كافة البلاغات؟ + + + Archive and upload + أرشفة و رفع + + + Archive report + أرشف البلاغ + + + Archive report? + أرشف البلاغ؟ + + + Archive reports + أرشف البلاغات + + + Ask + اسأل + + + Auto-accept settings + إعدادات القبول التلقائي + + + Better message dates. + تواريخ أفضل للرسائل. + + + Server added to operator %@. + تمت إضافة الخادم إلى المشغل %@. + + + Server address is incompatible with network settings: %@. + عنوان الخادم غير متوافق مع إعدادات الشبكة: %@. + + + Server protocol changed. + تغيّر بروتوكول الخادم. + + + SimpleX links are prohibited. + روابط SimpleX محظورة. + + + Additional accent + لون إضافي ثانوي + + + Always use private routing. + استخدم دائمًا التوجيه الخاص. + + + About operators + عن المُشغلين + + + Add team members + أضف أعضاء الفريق + + + Added media & file servers + أُضيفت خوادم الوسائط والملفات + + + Added message servers + أُضيفت خوادم الرسائل + + + Address or 1-time link? + عنوان أو رابط لمرة واحدة؟ + + + Address settings + إعدادات العنوان + + + Allow sharing + السماح بالمشاركة + + + App data migration + ترحيل بيانات التطبيق + + + Archive contacts to chat later. + أرشفة جهات الاتصال للدردشة لاحقًا. + + + Better networking + اتصال أفضل + + + Session code + رمز الجلسة + + + Set default theme + تعيين السمة الافتراضية + + + Set it instead of system authentication. + عيّنها بدلاً من استيثاق النظام. + + + Set the message shown to new members! + تعيين رسالة تظهر للأعضاء الجدد! + + + Share 1-time link + مشاركة رابط ذو استخدام واحد + + + Share 1-time link with a friend + شارك رابطًا لمرة واحدة مع صديق + + + Share SimpleX address on social media. + شارك عنوان SimpleX على وسائل التواصل الاجتماعي. + + + Share to SimpleX + المشاركة لSimpleX + + + Show calls in phone history + عرض المكالمات في سجل الهاتف + + + Show percentage + أظهِر النسبة المئوية + + + SimpleX + SimpleX + + + SimpleX Lock not enabled! + قفل SimpleX غير مفعّل! + + + Bad message hash + تجزئة رسالة سيئة + + + App session + جلسة التطبيق + + + SimpleX links not allowed + روابط SimpleX غير مسموح بها + + + All data is kept private on your device. + جميع البيانات تُحفظ بشكل خاص على جهازك. + + + Archived contacts + جهات الاتصال المؤرشفة + + + Show message status + أظهِر حالة الرسالة + + + Set message expiration in chats. + اضبط انتهاء صلاحية الرسالة في الدردشات. + + + Server address + عنوان الخادم + + + Show last messages + إظهار الرسائل الأخيرة + + + Server operator changed. + تغيّر مُشغل الخادم. + + + SimpleX address and 1-time links are safe to share via any messenger. + عنوان SimpleX والروابط لمرة واحدة آمنة للمشاركة عبر أي برنامج مُراسلة. + + + Add your team members to the conversations. + أضف أعضاء فريقك إلى المحادثات. + + + Advanced settings + إعدادات متقدّمة + + + Add to list + أضف إلى القائمة + + + Additional secondary + ثانوي إضافي + + + Admins can block a member for all. + يمكن للمُدراء حظر عضو للجميع. + + + All + الكل + + + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + جميع الرسائل والملفات تُرسل **مشفرة من النهاية-إلى-النهاية**، مع أمان ما-بعد-الحوسبة-الكمية في الرسائل المباشرة. + + + All new messages from %@ will be hidden! + جميع الرسائل الجديدة من %@ سيتم إخفاؤها! + + + Auto-accept + قبول تلقائي + + + Change self-destruct mode + تغيير وضع التدمير الذاتي + + + Chat database exported + صُدرت قاعدة بيانات الدردشة + + + Businesses + الشركات + + + Change automatic message deletion? + تغيير حذف الرسائل التلقائي؟ + + + Can't call contact + لا يمكن مكالمة جهة الاتصال + + + Chat list + قائمة الدردشات + + + Calls prohibited! + المكالمات ممنوعة! + + + Change lock mode + تغيير وضع القفل + + + Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. + توقفت الدردشة. إذا كنت قد استخدمت قاعدة البيانات هذه بالفعل على جهاز آخر، فيجب عليك نقلها مرة أخرى قبل بدء الدردشة. + + + Cellular + خلوي + + + Chat + الدردشة + + + Chat already exists! + الدردشة موجودة بالفعل! + + + Chat will be deleted for you - this cannot be undone! + سيتم حذف الدردشة لديك - لا يمكن التراجع عن هذا! + + + Chat will be deleted for all members - this cannot be undone! + سيتم حذف الدردشة لجميع الأعضاء - لا يمكن التراجع عن هذا! + + + Change self-destruct passcode + تغيير رمز المرور التدمير الذاتي + + + Camera not available + الكاميرا غير متوفرة + + + Capacity exceeded - recipient did not receive previously sent messages. + تم تجاوز السعة - لم يتلق المُستلم الرسائل المُرسلة مسبقًا. + + + Change passcode + تغيير رمز المرور + + + Chat colors + ألوان الدردشة + + + Chat theme + سمة الدردشة + + + Business address + عنوان العمل التجاري + + + Business chats + دردشات العمل التجاري + + + Cancel migration + ألغِ الترحيل + + + Change chat profiles + غيّر ملفات تعريف الدردشة + + + Chat migrated! + رحّلت الدردشة! + + + Chat profile + ملف تعريف الدردشة + + + Contact deleted! + حُذفت جهة الاتصال! + + + Conditions of use + شروط الاستخدام + + + Connecting + جارِ الاتصال + + + Connect incognito + اتصال متخفي + + + Created at + أُنشئ في + + + Connect via contact address + الاتصال عبر عنوان جهة الاتصال + + + Connected servers + الخوادم المتصلة + + + standard end-to-end encryption + التعمية القياسية بين الطرفين + + + Delete up to 20 messages at once. + حذف ما يصل إلى 20 رسالة في آن واحد. + + + Connect to your friends faster. + تواصل مع أصدقائك بشكل أسرع. + + + Developer options + خيارات المطور + + + Connect to yourself? + اتصل بنفسك؟ + + + Connect via one-time link + اتصال عبر رابط لمرة واحدة + + + Connect to yourself? +This is your own SimpleX address! + اتصل بنفسك؟ +هذا هو عنوان SimpleX الخاص بك! + + + Connecting to contact, please wait or check later! + جارِ الاتصال بجهة الاتصال، يُرجى الانتظار أو التحقق لاحقًا! + + + Database upgrade + ترقية قاعدة البيانات + + + Create list + أنشئ قائمة + + + Create profile + إنشاء ملف تعريف + + + Creating archive link + جارِ إنشاء رابط الأرشيف + + + Details + التفاصيل + + + Customize theme + تخصيص السمة + + + Dark mode colors + ألوان الوضع الداكن + + + Delete and notify contact + حذف وإشعار جهة الاتصال + + + Deleted at: %@ + حُذفت في: %@ + + + Detailed statistics + إحصائيات مفصلة + + + you are observer + أنت المراقب + + + you + أنت + + + when IP hidden + عندما يكون IP مخفيًا + + + video + فيديو + + + Clear or delete group? + مسح أو حذف المجموعة؟ + + + Clear private notes? + مسح الملاحظات الخاصة؟ + + + Community guidelines violation + انتهاك إرشادات المجتمع + + + Connection not ready. + الاتصال غير جاهز. + + + Connection requires encryption renegotiation. + يتطلب الاتصال إعادة التفاوض على التعمية. + + + Contact is deleted. + حُذفت جهة الاتصال. + + + Contacts + جهات الاتصال + + + Create SimpleX address + أنشئ عنوان SimpleX + + + Current conditions text couldn't be loaded, you can review conditions via this link: + لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: + + + Delete chat messages from your device. + احذف رسائل الدردشة من جهازك. + + + Delete or moderate up to 200 messages. + حذف أو إشراف ما يصل إلى 200 رسالة. + + + Delete profile + حذف ملف التعريف + + + Desktop devices + أجهزة سطح المكتب + + + set new profile picture + عيّن صورة تعريفية جديدة + + + weeks + أسابيع + + + Chunks uploaded + رُفع القطع + + + Color mode + وضع اللون + + + Created + أُنشئت + + + Current Passcode + رمز المرور الحالي + + + Custom time + وقت مخصّص + + + Debug delivery + تسليم التصحيح + + + Deleted + حُذفت + + + Delete file + حذف الملف + + + unknown status + حالة غير معروفة + + + unknown servers + خوادم غير معروفة + + + Connect to yourself? +This is your own one-time link! + اتصل بنفسك؟ +هذا هو الرابط الخاص بك لمرة واحدة! + + + Connect with %@ + الاتصال ب%@ + + + Connected desktop + سطح المكتب متصل + + + Connected to desktop + متصل بسطح المكتب + + + Conversation deleted! + حُذفت المحادثة! + + + Create a group using a random profile. + أنشئ مجموعة باستخدام ملف تعريف عشوائي. + + + Delete chat + احذف الدردشة + + + Delete chat profile + حذف ملف تعريف الدردشة + + + Delete chat? + حذف الدردشة؟ + + + Delete database from this device + احذف قاعدة البيانات من هذا الجهاز + + + Delivery + التوصيل + + + Delivery receipts are disabled! + إيصالات التسليم مُعطَّلة! + + + Connection terminated + انتهى الاتصال + + + Create file + إنشاء ملف + + + Create group + أنشئ مجموعة + + + Database IDs and Transport isolation option. + معرفات قاعدة البيانات وخيار عزل النقل. + + + Database downgrade + الرجوع إلى إصدار سابق من قاعدة البيانات + + + Delivery receipts! + إيصالات التسليم! + + + Desktop address + عنوان سطح المكتب + + + updated profile + حدّثت ملف التعريف + + + Connect to desktop + اتصل بسطح المكتب + + + Connecting to desktop + جار الاتصال بسطح المكتب + + + Completed + اكتملت + + + Connection notifications + إشعارات الاتصال + + + Connection and servers status. + حالة الاتصال والخوادم. + + + Continue + متابعة + + + Connections + الاتصالات + + + Content violates conditions of use + المحتوى ينتهك شروط الاستخدام + + + Corner + ركن + + + Creating link… + جارِ إنشاء الرابط… + + + Database ID: %d + معرّف قاعدة البيانات: %d + + + Decryption error + خطأ في فك التعمية + + + Delete report + احذف البلاغ + + + Delete without notification + احذف دون إشعار + + + Deleted at + حُذفت في + + + Clear group? + مسح المجموعة؟ + + + Compare file + قارن الملف + + + Connect automatically + اتصل تلقائيًا + + + Connection blocked + حُظر الاتصال + + + unprotected + غير محمي + + + Deletion errors + أخطاء الحذف + + + Conditions will be accepted for enabled operators after 30 days. + سيتم قبول الشروط للمُشغلين المفعّلين بعد 30 يومًا. + + + Connection security + أمان الاتصال + + + Contact will be deleted - this cannot be undone! + سيتم حذف جهة الاتصال - لا يمكن التراجع عن هذا! + + + Copy error + خطأ في النسخ + + + Create 1-time link + أنشئ رابط لمرة واحدة + + + Connected + متصل + + + Current profile + ملف التعريف الحالي + + + Customizable message shape. + شكل الرسالة قابل للتخصيص. + + + Chunks deleted + حُذفت القطع + + + Chinese and Spanish interface + الواجهة الصينية والاسبانية + + + Download + نزّل + + + Downloaded + نُزّلت + + + Downloaded files + الملفات التي نُزّلت + + + Don't show again + لا تُظهر مرة أخرى + + + Confirm contact deletion? + تأكيد حذف جهة الاتصال؟ + + + Confirm database upgrades + تأكيد ترقيات قاعدة البيانات + + + Download failed + فشل التنزيل + + + Download file + نزّل الملف + + + Downloading link details + جارِ تنزيل تفاصيل الرابط + + + Downloading archive + جارِ تنزيل الأرشيف + + + Don't enable + لا تُفعل + + + Confirm upload + أكّد الرفع + + + Chunks downloaded + نُزّلت القطع + + + Confirm Passcode + تأكيد رمز المرور + + + Confirm files from unknown servers. + تأكيد الملفات من خوادم غير معروفة. + + + Confirm network settings + أكّد إعدادات الشبكة + + + Confirm that you remember database passphrase to migrate it. + تأكد من أنك تتذكر عبارة مرور قاعدة البيانات لترحيلها. + + + Downgrade and open chat + الرجوع إلى إصدار سابق وفتح الدردشة + + + Don't miss important messages. + لا تفوت رسائل مهمة. + + + E2E encrypted notifications. + إشعارات مُشفرة بين الطرفين E2E + + + Download errors + أخطاء التنزيل + + + Download files + نزّل الملفات + + + Confirm password + تأكيد كلمة المرور + + + Enable self-destruct + تفعيل التدمير الذاتي + + + Enable (keep overrides) + تفعيل (الاحتفاظ بالتجاوزات) + + + Enable Flux + فعّل flux + + + Enable in direct chats (BETA)! + فعّل في الدردشات المباشرة (تجريبي)! + + + Enable for all + تفعيل للجميع + + + Enable lock + تفعيل القفل + + + Enable camera access + فعّل الوصول إلى الكاميرا + + + Enable self-destruct passcode + تفعيل رمز التدمير الذاتي + + + Can't message member + لا يمكن الاتصال بالعضو + + + Color chats with the new themes. + محادثات ملونة مع السمات الجديدة. + + + All chats will be removed from the list %@, and the list deleted. + ستتم إزالة جميع الدردشات من القائمة %@، وسيتم حذف القائمة. + + + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + البلغارية والفنلندية والتايلاندية والأوكرانية - شكرًا للمستخدمين و[Weblate] (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + + + Choose _Migrate from another device_ on the new device and scan QR code. + اختر _الترحيل من جهاز آخر_ على الجهاز الجديد وامسح رمز الاستجابة السريعة. + + + Conditions will be accepted for the operator(s): **%@**. + سيتم قبول شروط المشغل (المشغلين): **%@**. + + + Conditions will be accepted on: %@. + سيتم قبول الشروط على: %@. + + + Confirmed + تم التأكيد + + + Connection is blocked by server operator: +%@ + تم حظر الاتصال من قبل مشغل الخادم: +%@ + + + Can't call member + لا يمكن الاتصال بالعضو + + + Chat already exists + الدردشة موجودة بالفعل + + + Check messages every 20 min. + تحقق من الرسائل كل 20 دقيقة. + + + Check messages when allowed. + تحقق من الرسائل عندما يُسمح بذلك. + + + Cannot forward message + لا يمكن إعادة توجيه الرسالة + + + Chat preferences were changed. + تم تغيير تفضيلات المحادثة. + + + Conditions are already accepted for these operator(s): **%@**. + الشروط مقبولة بالفعل لهذا المشغل (المشغلين): **%@**. + + + Conditions will be accepted for operator(s): **%@**. + سيتم قبول شروط المشغل (المشغلين): **%@**. + + + Conditions accepted on: %@. + الشروط المقبولة على: %@. + + + Conditions are accepted for the operator(s): **%@**. + يتم قبول شروط المشغل (المشغلين): **%@**. + + + Conditions will be automatically accepted for enabled operators on: %@. + سيتم قبول الشروط تلقائيًا للمشغلين الممكّنين على: %@. + + + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + أنشئ ملفًا شخصيًا جديدًا في [تطبيق سطح المكتب](https://simplex.chat/downloads/). 💻 + + + Error adding server + خطأ في إضافة الخادم + + + Created at: %@ + تم الإنشاء في: %@ + + + Delete %lld messages of members? + حذف %lld الرسائل القديمة للأعضاء؟ + + + Disappearing message + رسالة اختفاء + + + Enabled + ممكّنة + + + Encrypted message: database migration error + رسالة مشفرة: خطأ في ترحيل قاعدة البيانات + + + Delete list? + Delete list? + + + Delivered even when Apple drops them. + يتم تسليمها حتى عندما تسقطها شركة Apple. + + + Destination server address of %@ is incompatible with forwarding server %@ settings. + عنوان خادم الوجهة %@ غير متوافق مع إعدادات خادم التوجيه %@. + + + Destination server version of %@ is incompatible with forwarding server %@. + إصدار خادم الوجهة لـ %@ غير متوافق مع خادم التوجيه %@. + + + Don't create address + لا تنشئ عنوان + + + Done + تم + + + Duration + المدة + + + Encrypt local files + تشفير الملفات المحلية + + + Encryption renegotiation in progress. + إعادة التفاوض على التشفير قيد التنفيذ. + + + Enter Passcode + أدخل رمز المرور + + + Enter passphrase + قم بأدخل عبارة المرور + + + Enter welcome message… + أدخل رسالة ترحيب… + + + Enter your name… + أدخل اسمك… + + + Error changing to incognito! + خطأ في التغيير إلى التصفح المتخفي! + + + Delete %lld messages? + حذف %lld رسائل؟ + + + Error aborting address change + خطأ في إجهاض تغيير العنوان + + + Disappears at + يختفي عند + + + Do not use credentials with proxy. + لا تستخدم بيانات الاعتماد مع البروكسي. + + + Error accepting conditions + خطأ في قبول الشروط + + + Enter password above to show! + أدخل كلمة المرور أعلاه للعرض! + + + Error changing connection profile + خطأ في تغيير ملف تعريف الاتصال + + + Desktop app version %@ is not compatible with this app. + إصدار تطبيق سطح المكتب %@ غير متوافق مع هذا التطبيق. + + + Encrypt stored files & media + تشفير الملفات والوسائط المخزنة + + + Enter this device name… + أدخل اسم الجهاز… + + + Enter welcome message… (optional) + أدخل رسالة ترحيب... (اختياري) + + + Correct name to %@? + الاسم الصحيح ل %@؟ + + + Delete member message? + حذف رسالة العضو؟ + + + Disable automatic message deletion? + تعطيل حذف الرسائل التلقائي؟ + + + Disable delete messages + تعطيل حذف الرسائل + + + Disable for all + تعطيل للجميع + + + Disabled + عاجز + + + Documents: + المستندات: + + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + باستخدامك SimpleX Chat، فإنك توافق على: +- إرسال محتوى قانوني فقط في المجموعات العامة. +- احترام المستخدمين الآخرين - ممنوع إرسال رسائل مزعجة. + + + Configure server operators + تكوين مشغلي الخادم + + + Enable Flux in Network & servers settings for better metadata privacy. + تمكين التدفق في إعدادات الشبكة والخوادم لتحسين خصوصية البيانات الوصفية. + + + Discover and join groups + اكتشف المجموعات وانضم إليها + + + Discover via local network + اكتشف عبر الشبكة المحلية + + + Enabled for + ممكّن ل + + + Encrypted message: app is stopped + رسالة مشفرة: تم إيقاف التطبيق + + + Enter group name… + أدخل اسم المجموعة… + + + Do NOT use private routing. + لا تستخدم التوجيه الخاص. + + + Encryption re-negotiation error + خطأ في إعادة تفاوض التشفير + + + Connection with desktop stopped + تم إيقاف الاتصال بسطح المكتب + + + Destination server error: %@ + خطأ خادم الوجهة: %@ + + + Do NOT send messages directly, even if your or destination server does not support private routing. + لا ترسل الرسائل بشكل مباشر، حتى لو كان خادمك أو خادم الوجهة لا يدعم التوجيه الخاص. + + + Direct messages between members are prohibited in this chat. + يُحظر إرسال الرسائل المباشرة بين الأعضاء في هذه الدردشة. + + + Disconnect desktop? + فصل سطح المكتب؟ + + + Disable (keep overrides) + تعطيل (الاحتفاظ بالتجاوزات) + + + Disappears at: %@ + يختفي عند: %@ + + + Do not send history to new members. + لا ترسل التاريخ إلى الأعضاء الجدد. + + + Encryption re-negotiation failed. + فشل إعادة التفاوض على التشفير. + @@ -3925,4 +5805,80 @@ SimpleX servers cannot see your profile. + + + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + يمكنك السماح بالمشاركة في إعدادات الخصوصية والأمان / اعدادات "SimpleX Lock" + + + Keychain error + خطأ في Keychain + + + Invalid migration confirmation + تأكيد الترحيل غير صالح + + + %@ + %@ + + + Share + مشاركة + + + Incompatible database version + إصدار قاعدة بيانات غير متوافق + + + File error + خطأ في الملف + + + Database downgrade required + مطلوب الرجوع إلى إصدار سابق من قاعدة البيانات‎ + + + Database encrypted! + قاعدة البيانات مُعمّاة! + + + Wrong database passphrase + عبارة مرور قاعدة بيانات خاطئة + + + Selected chat preferences prohibit this message. + تفضيلات الدردشة المحدّدة تحظر هذه الرسالة. + + + Database error + خطأ في قاعدة البيانات + + + Database passphrase is required to open chat. + عبارة مرور قاعدة البيانات مطلوبة لفتح الدردشة. + + + Error: %@ + خطأ: %@ + + + Cancel + إلغاء + + + Large file! + الملف كبير! + + + + + + + From: %@ + من: %@ + + + diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 4aa1f2213f..b5a217d8c0 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (може да се копира) @@ -132,6 +120,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -194,6 +183,10 @@ %d сек. time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропуснато(и) съобщение(я) @@ -264,11 +257,6 @@ %lld нови езици на интерфейса No comment provided by engineer. - - %lld second(s) - %lld секунда(и) - No comment provided by engineer. - %lld seconds %lld секунди @@ -319,11 +307,6 @@ %u пропуснати съобщения. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (ново) @@ -334,11 +317,6 @@ (това устройство v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Добави контакт**: за създаване на нов линк. @@ -403,11 +381,6 @@ \*удебелен* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -444,11 +417,6 @@ - история на редактиране. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 сек @@ -462,7 +430,8 @@ 1 day 1 ден - time interval + delete after time +time interval 1 hour @@ -477,15 +446,22 @@ 1 month 1 месец - time interval + delete after time +time interval 1 week 1 седмица - time interval + delete after time +time interval + + + 1 year + delete after time 1-time link + Еднократен линк No comment provided by engineer. @@ -507,11 +483,6 @@ 30 секунди No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -568,21 +539,32 @@ About operators + За операторите No comment provided by engineer. Accent + Акцент No comment provided by engineer. Accept Приеми accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions + Приеми условията No comment provided by engineer. @@ -599,22 +581,34 @@ Accept incognito Приеми инкогнито accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions + Приети условия No comment provided by engineer. Acknowledged + Потвърден No comment provided by engineer. Acknowledgement errors + Грешки при потвърждението No comment provided by engineer. + + Active + token status text + Active connections + Активни връзки No comment provided by engineer. @@ -624,6 +618,11 @@ Add friends + Добави приятели + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -643,6 +642,7 @@ Add team members + Добави членове на екипа No comment provided by engineer. @@ -650,6 +650,10 @@ Добави към друго устройство No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Добави съобщение при посрещане @@ -657,26 +661,32 @@ Add your team members to the conversations. + Добавете членовете на вашия екип към разговорите. No comment provided by engineer. Added media & file servers + Добавени медийни и файлови сървъри No comment provided by engineer. Added message servers + Добавени сървъри за съобщения No comment provided by engineer. Additional accent + Допълнителен акцент No comment provided by engineer. Additional accent 2 + Допълнителен акцент 2 No comment provided by engineer. Additional secondary + Допълнителен вторичен No comment provided by engineer. @@ -691,10 +701,12 @@ Address or 1-time link? + Адрес или еднократен линк? No comment provided by engineer. Address settings + Настройки на адреса No comment provided by engineer. @@ -714,6 +726,11 @@ Advanced settings + Разширени настройки + No comment provided by engineer. + + + All No comment provided by engineer. @@ -726,6 +743,10 @@ Всички чатове и съобщения ще бъдат изтрити - това не може да бъде отменено! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. Всички данни се изтриват при въвеждане. @@ -733,6 +754,7 @@ All data is kept private on your device. + Всички данни се съхраняват поверително на вашето устройство. No comment provided by engineer. @@ -761,8 +783,17 @@ All profiles + Всички профили profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Всички ваши контакти ще останат свързани. @@ -790,6 +821,7 @@ Allow calls? + Позволи обаждания? No comment provided by engineer. @@ -799,6 +831,7 @@ Allow downgrade + Позволи понижаване No comment provided by engineer. @@ -828,6 +861,7 @@ Allow sharing + Позволи споделяне No comment provided by engineer. @@ -835,6 +869,10 @@ Позволи необратимо изтриване на изпратените съобщения. (24 часа) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Разрешаване на изпращане на SimpleX линкове. @@ -902,6 +940,7 @@ Always use private routing. + Винаги използвай поверително рутиране. No comment provided by engineer. @@ -914,6 +953,10 @@ Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. No comment provided by engineer. + + Another reason + report reason + Answer call Отговор на повикване @@ -939,6 +982,10 @@ Приложението криптира нови локални файлове (с изключение на видеоклипове). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Икона на приложението @@ -956,6 +1003,7 @@ App session + Сесия на приложението No comment provided by engineer. @@ -980,6 +1028,19 @@ Apply to + Приложи към + No comment provided by engineer. + + + Archive + No comment provided by engineer. + + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? No comment provided by engineer. @@ -989,10 +1050,24 @@ Archive contacts to chat later. + Архивирайте контактите, за да разговаряте по-късно. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + + + Archive reports + swipe action + Archived contacts + Архивирани контакти No comment provided by engineer. @@ -1062,6 +1137,7 @@ Auto-accept settings + Автоматично приемане на настройки alert title @@ -1071,6 +1147,7 @@ Background + Фон No comment provided by engineer. @@ -1090,6 +1167,7 @@ Better calls + По-добри обаждания No comment provided by engineer. @@ -1097,8 +1175,13 @@ По-добри групи No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. + По-добри дати на съобщението. No comment provided by engineer. @@ -1108,22 +1191,31 @@ Better networking + Подобрена мрежа No comment provided by engineer. Better notifications + Подобрени известия + No comment provided by engineer. + + + Better privacy and security No comment provided by engineer. Better security ✅ + По-добра сигурност ✅ No comment provided by engineer. Better user experience + Подобрен интерфейс No comment provided by engineer. Black + Черна No comment provided by engineer. @@ -1163,10 +1255,12 @@ Blur for better privacy. + Размазване за по-добра поверителност. No comment provided by engineer. Blur media + Размазване на медия No comment provided by engineer. @@ -1201,10 +1295,16 @@ Business address + Бизнес адрес No comment provided by engineer. Business chats + Бизнес чатове + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1212,6 +1312,12 @@ Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Разговорът вече приключи! @@ -1224,6 +1330,7 @@ Calls prohibited! + Обажданията са забранени! No comment provided by engineer. @@ -1233,10 +1340,12 @@ Can't call contact + Обаждането на контакта не е позволено No comment provided by engineer. Can't call member + Обаждането на члена не е позволено No comment provided by engineer. @@ -1257,7 +1366,7 @@ Cancel Отказ alert action - alert button +alert button Cancel migration @@ -1280,6 +1389,7 @@ Capacity exceeded - recipient did not receive previously sent messages. + Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения. snd error text @@ -1292,8 +1402,13 @@ Промени No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Промени чат профилите authentication reason @@ -1340,7 +1455,7 @@ Change self-destruct passcode Промени кода за достъп за самоунищожение authentication reason - set passcode view +set passcode view Chat @@ -1365,7 +1480,7 @@ Chat database - База данни за чата + База данни No comment provided by engineer. @@ -1379,7 +1494,7 @@ Chat database imported - Базата данни на чат е импортирана + Базата данни на е импортирана No comment provided by engineer. @@ -1432,11 +1547,23 @@ Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Чатове No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1497,6 +1624,14 @@ Изчисти разговора? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Изчистване на лични бележки? @@ -1515,6 +1650,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Сравни файл @@ -1545,14 +1684,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1570,6 +1701,10 @@ Конфигурирай ICE сървъри No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Потвърди @@ -1618,6 +1753,10 @@ Потвърди качването No comment provided by engineer. + + Confirmed + token status text + Connect Свързване @@ -1731,6 +1870,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Грешка при свързване @@ -1741,6 +1884,15 @@ This is your own one-time link! Грешка при свързване (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1750,6 +1902,10 @@ This is your own one-time link! Заявката за връзка е изпратена! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1824,6 +1980,10 @@ This is your own one-time link! Контактите могат да маркират съобщения за изтриване; ще можете да ги разглеждате. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продължи @@ -1858,31 +2018,32 @@ This is your own one-time link! Create - Създай + Създаване No comment provided by engineer. Create 1-time link + Създаване на еднократна препратка No comment provided by engineer. Create SimpleX address - Създай SimpleX адрес + Създаване на адрес в SimpleX No comment provided by engineer. Create a group using a random profile. - Създай група с автоматично генериран профилл. + Създаване група с автоматично създаден профил. No comment provided by engineer. Create file - Създай файл + Създаване на файл server test step Create group - Създай група + Създаване на група No comment provided by engineer. @@ -1895,6 +2056,10 @@ This is your own one-time link! Създай линк No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Създайте нов профил в [настолното приложение](https://simplex.chat/downloads/). 💻 @@ -2105,8 +2270,7 @@ This is your own one-time link! Delete Изтрий alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2146,6 +2310,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Изтрий чат профила @@ -2156,6 +2324,10 @@ This is your own one-time link! Изтриване на чат профила? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2234,6 +2406,10 @@ This is your own one-time link! Изтрий линк? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Изтрий съобщението на члена? @@ -2247,7 +2423,7 @@ This is your own one-time link! Delete messages Изтрий съобщенията - No comment provided by engineer. + alert button Delete messages after @@ -2283,6 +2459,10 @@ This is your own one-time link! Изтрий опашка server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2431,6 +2611,14 @@ This is your own one-time link! Деактивирай SimpleX заключване authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Деактивиране за всички @@ -2517,6 +2705,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не създавай адрес @@ -2527,9 +2719,17 @@ This is your own one-time link! Не активирай No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Не показвай отново + alert action + + + Done No comment provided by engineer. @@ -2541,7 +2741,7 @@ This is your own one-time link! Download Изтегли alert button - chat item action +chat item action Download errors @@ -2613,8 +2813,8 @@ This is your own one-time link! Активиране (запазване на промените) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2630,7 +2830,7 @@ This is your own one-time link! Enable automatic message deletion? Активиране на автоматично изтриване на съобщения? - No comment provided by engineer. + alert title Enable camera access @@ -2756,6 +2956,10 @@ This is your own one-time link! Неуспешно повторно договаряне на криптирането. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Въведете kодa за достъп @@ -2830,6 +3034,10 @@ This is your own one-time link! Грешка при приемане на заявка за контакт No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Грешка при добавяне на член(ове) @@ -2862,6 +3070,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -2881,6 +3093,10 @@ This is your own one-time link! Грешка при създаване на групов линк No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Грешка при създаване на контакт с член @@ -2896,6 +3112,10 @@ This is your own one-time link! Грешка при създаване на профил! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Грешка при декриптирането на файла @@ -2903,9 +3123,13 @@ This is your own one-time link! Error deleting chat database - Грешка при изтриване на чат базата данни + Грешка при изтриване на базата данни No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Грешка при изтриването на чата! @@ -2958,7 +3182,7 @@ This is your own one-time link! Error exporting chat database - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни No comment provided by engineer. @@ -2967,7 +3191,7 @@ This is your own one-time link! Error importing chat database - Грешка при импортиране на чат базата данни + Грешка при импортиране на базата данни No comment provided by engineer. @@ -3001,10 +3225,18 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Грешка при отстраняване на член - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -3015,6 +3247,10 @@ This is your own one-time link! Грешка при запазване на ICE сървърите No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Грешка при запазване на профила на групата @@ -3093,6 +3329,10 @@ This is your own one-time link! Грешка при синхронизиране на връзката No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Грешка при актуализиране на груповия линк @@ -3135,7 +3375,9 @@ This is your own one-time link! Error: %@ Грешка: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3170,6 +3412,10 @@ This is your own one-time link! Разшири chat item action + + Expired + token status text + Export database Експортирай база данни @@ -3209,25 +3455,42 @@ This is your own one-time link! Бързо и без чакане, докато подателят е онлайн! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. По-бързо присъединяване и по-надеждни съобщения. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Любим swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3348,6 +3611,10 @@ This is your own one-time link! Поправката не се поддържа от члена на групата No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3361,6 +3628,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -3471,6 +3742,10 @@ Error: %2$@ GIF файлове и стикери No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3574,11 +3849,19 @@ Error: %2$@ Групата ще бъде изтрита за вас - това не може да бъде отменено! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Помощ No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Скрит @@ -3637,6 +3920,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Информация @@ -3718,7 +4005,7 @@ Error: %2$@ Import chat database? - Импортиране на чат база данни? + Импортиране на база данни? No comment provided by engineer. @@ -3775,6 +4062,14 @@ More improvements are coming soon! Звуци по време на разговор No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Инкогнито @@ -3866,6 +4161,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Невалиден QR код @@ -4134,6 +4449,18 @@ This is your link for group %@! Запомнени настолни устройства No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Съобщение на живо! @@ -4217,10 +4544,18 @@ This is your link for group %@! Член No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4244,6 +4579,10 @@ This is your link for group %@! Членът ще бъде премахнат от групата - това не може да бъде отменено! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Членовете на групата могат да добавят реакции към съобщенията. @@ -4254,6 +4593,10 @@ This is your link for group %@! Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Членовете на групата могат да изпращат SimpleX линкове. @@ -4279,6 +4622,10 @@ This is your link for group %@! Членовете на групата могат да изпращат гласови съобщения. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4379,6 +4726,10 @@ This is your link for group %@! Съобщенията от %@ ще бъдат показани! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4476,6 +4827,10 @@ This is your link for group %@! Модерирано в: %@ copied message info + + More + swipe action + More improvements are coming soon! Очаквайте скоро още подобрения! @@ -4503,7 +4858,11 @@ This is your link for group %@! Mute Без звук - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4552,6 +4911,10 @@ This is your link for group %@! Състояние на мрежата No comment provided by engineer. + + New + token status text + New Passcode Нов kод за достъп @@ -4612,6 +4975,10 @@ This is your link for group %@! Нова членска роля No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Ново съобщение @@ -4636,6 +5003,22 @@ This is your link for group %@! Приложението няма kод за достъп Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Няма избрани контакти @@ -4683,6 +5066,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -4731,6 +5118,14 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. Първата платформа без никакви потребителски идентификатори – поверителна по дизайн. @@ -4741,6 +5136,10 @@ This is your link for group %@! Несъвместим! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4759,10 +5158,18 @@ This is your link for group %@! Известията са деактивирани! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4785,7 +5192,8 @@ This is your link for group %@! Ok Ок - alert button + alert action +alert button Old database @@ -4844,6 +5252,14 @@ Requires compatible VPN. Само собствениците на групата могат да активират гласови съобщения. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Само вие можете да добавяте реакции на съобщенията. @@ -4897,7 +5313,7 @@ Requires compatible VPN. Open Отвори - No comment provided by engineer. + alert action Open Settings @@ -4927,6 +5343,10 @@ Requires compatible VPN. Отвори група No comment provided by engineer. + + Open link? + alert title + Open migration to another device Отвори миграцията към друго устройство @@ -4973,6 +5393,10 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Други @@ -5156,6 +5580,22 @@ Error: %@ Моля, съхранявайте паролата на сигурно място, НЯМА да можете да я промените, ако я загубите. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Полски интерфейс @@ -5202,16 +5642,28 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Поверителността преосмислена No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Поверителни имена на файлове No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -5282,6 +5734,10 @@ Error: %@ Забрани реакциите на съобщенията. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Забранете изпращането на SimpleX линкове. @@ -5523,11 +5979,23 @@ Enable in *Network & servers* settings. Намалена консумация на батерията No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Отхвърляне reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5539,6 +6007,10 @@ Enable in *Network & servers* settings. Отхвърли заявката за контакт No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Реле сървър се използва само ако е необходимо. Друга страна може да наблюдава вашия IP адрес. @@ -5622,6 +6094,50 @@ Enable in *Network & servers* settings. Отговори chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required Задължително @@ -5706,9 +6222,13 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5756,13 +6276,17 @@ Enable in *Network & servers* settings. Save Запази alert button - chat item action +chat item action Save (and notify contacts) Запази (и уведоми контактите) alert button + + Save admission settings? + alert title + Save and notify contact Запази и уведоми контакта @@ -5787,6 +6311,10 @@ Enable in *Network & servers* settings. Запази профила на групата No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Запази паролата и отвори чата @@ -6014,6 +6542,10 @@ Enable in *Network & servers* settings. Изпращай известия No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Изпращайте въпроси и идеи @@ -6211,6 +6743,10 @@ Enable in *Network & servers* settings. Задай 1 ден No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Задай име на контакт… @@ -6230,6 +6766,14 @@ Enable in *Network & servers* settings. Задайте го вместо системната идентификация. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Задай kод за достъп @@ -6273,7 +6817,7 @@ Enable in *Network & servers* settings. Share Сподели alert action - chat item action +chat item action Share 1-time link @@ -6329,6 +6873,10 @@ Enable in *Network & servers* settings. Сподели с контактите No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Покажи QR код @@ -6422,6 +6970,10 @@ Enable in *Network & servers* settings. SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX адрес за контакт @@ -6516,6 +7068,11 @@ Enable in *Network & servers* settings. Някой notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. Квадрат, кръг или нещо между тях. @@ -6599,6 +7156,10 @@ Enable in *Network & servers* settings. Спиране на чата No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6652,6 +7213,10 @@ Enable in *Network & servers* settings. Времето на изчакване за установяване на TCP връзка No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6717,13 +7282,17 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. Тестът е неуспешен на стъпка %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Тествай сървър @@ -6841,19 +7410,10 @@ It can happen because of some bug or when the connection is compromised.Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита. No comment provided by engineer. - - The profile is only shared with your contacts. - Профилът се споделя само с вашите контакти. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -6914,6 +7474,10 @@ It can happen because of some bug or when the connection is compromised.Това действие не може да бъде отменено - съобщенията, изпратени и получени по-рано от избраното, ще бъдат изтрити. Може да отнеме няколко минути. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Това действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. @@ -6959,10 +7523,18 @@ It can happen because of some bug or when the connection is compromised.Това е вашят еднократен линк за връзка! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Тази настройка се прилага за съобщения в текущия ви профил **%@**. @@ -7066,6 +7638,10 @@ You will be prompted to complete authentication before this feature is enabled.< Избор на инкогнито при свързване. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -7226,13 +7802,17 @@ To connect, please ask your contact to create another connection link and check Unmute Уведомявай - swipe action + notification label action Unread Непрочетено swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. На новите членове се изпращат до последните 100 съобщения. @@ -7257,6 +7837,10 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Актуализирането на настройките ще свърже отново клиента към всички сървъри. @@ -7312,6 +7896,14 @@ To connect, please ask your contact to create another connection link and check Използвай сървърите на SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Използвай чата @@ -7372,6 +7964,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Използвайте приложението по време на разговора. @@ -7381,6 +7977,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7738,10 +8338,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -7828,6 +8424,10 @@ Repeat join request? Можете да видите отново линкът за покана в подробностите за връзката. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Не може да изпращате съобщения! @@ -7907,6 +8507,10 @@ Repeat connection request? Изпратихте покана за групата No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Ще бъдете свързани с групата, когато устройството на домакина на групата е онлайн, моля, изчакайте или проверете по-късно! @@ -7971,14 +8575,9 @@ Repeat connection request? Вашите ICE сървъри No comment provided by engineer. - - Your SMP servers - Вашите SMP сървъри - No comment provided by engineer. - Your SimpleX address - Вашият SimpleX адрес + Вашият адрес в SimpleX No comment provided by engineer. @@ -7988,12 +8587,12 @@ Repeat connection request? Your chat database - Вашата чат база данни + Вашата база данни No comment provided by engineer. Your chat database is not encrypted - set passphrase to encrypt it. - Вашата чат база данни не е криптирана - задайте парола, за да я криптирате. + Вашата база данни не е криптирана - задайте парола, за да я криптирате. No comment provided by engineer. @@ -8030,7 +8629,7 @@ Repeat connection request? Your current chat database will be DELETED and REPLACED with the imported one. - Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. + Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. No comment provided by engineer. @@ -8058,6 +8657,11 @@ Repeat connection request? Вашият профил **%@** ще бъде споделен. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Профилът се споделя само с вашите контакти. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил. @@ -8067,11 +8671,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство. - No comment provided by engineer. - Your random profile Вашият автоматично генериран профил @@ -8121,6 +8720,10 @@ Repeat connection request? по-горе, след това избери: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call обаждането прието @@ -8130,6 +8733,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin админ @@ -8150,6 +8757,10 @@ Repeat connection request? съгласуване на криптиране… chat item text + + all + member criteria value + all members всички членове @@ -8165,6 +8776,10 @@ Repeat connection request? и %lld други събития No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8202,7 +8817,8 @@ Repeat connection request? blocked by admin блокиран от админ - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8228,6 +8844,10 @@ Repeat connection request? повикване… call status + + can't send messages + No comment provided by engineer. + cancelled %@ отменен %@ @@ -8333,6 +8953,14 @@ Repeat connection request? името на контакта %1$@ е променено на %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption контактът има e2e криптиране @@ -8343,6 +8971,10 @@ Repeat connection request? контактът няма e2e криптиране No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator създател @@ -8370,7 +9002,8 @@ Repeat connection request? default (%@) по подразбиране (%@) - pref value + delete after time +pref value default (no) @@ -8394,7 +9027,7 @@ Repeat connection request? deleted group - групата изтрита + групата е изтрита rcv group event chat item @@ -8496,19 +9129,10 @@ Repeat connection request? грешка No comment provided by engineer. - - event happened - събитие се случи - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded препратено @@ -8519,6 +9143,10 @@ Repeat connection request? групата е изтрита No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated профилът на групата е актуализиран @@ -8642,6 +9270,10 @@ Repeat connection request? свързан rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8671,19 +9303,19 @@ Repeat connection request? модерирано от %@ marked deleted chat item preview text + + moderator + member role + months месеци time unit - - mute - No comment provided by engineer. - never никога - No comment provided by engineer. + delete after time new message @@ -8705,6 +9337,10 @@ Repeat connection request? няма текст copied message info in history + + not synchronized + No comment provided by engineer. + observer наблюдател @@ -8714,8 +9350,9 @@ Repeat connection request? off изключено enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -8755,6 +9392,18 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption квантово устойчиво e2e криптиране @@ -8770,6 +9419,10 @@ Repeat connection request? получено потвърждение… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call отхвърлено повикване @@ -8790,6 +9443,10 @@ Repeat connection request? премахнат адрес за контакт profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture премахната профилна снимка @@ -8800,10 +9457,22 @@ Repeat connection request? ви острани rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved запазено @@ -8898,10 +9567,6 @@ last received msg: %2$@ неизвестен статус No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. @@ -8989,6 +9654,10 @@ last received msg: %2$@ вие No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group вие сте поканени в групата @@ -9063,7 +9732,7 @@ last received msg: %2$@
- +
@@ -9100,7 +9769,7 @@ last received msg: %2$@
- +
@@ -9122,13 +9791,17 @@ last received msg: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9141,15 +9814,11 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -9168,7 +9837,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/contents.json b/apps/ios/SimpleX Localizations/bg.xcloc/contents.json index 5356e25a2e..66d64e6539 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/bg.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "bg", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff index 7002f790df..bf7753675e 100644 --- a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff +++ b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff @@ -3422,8 +3422,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 668888c20e..fe0e02ccdf 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (lze kopírovat) @@ -94,6 +82,7 @@ %@ downloaded + %@ staženo No comment provided by engineer. @@ -113,10 +102,12 @@ %@ server + %@ server No comment provided by engineer. %@ servers + %@ servery No comment provided by engineer. @@ -130,6 +121,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -153,18 +145,22 @@ %d file(s) are still being downloaded. + %d soubor(y) stále stahován(y). forward confirmation reason %d file(s) failed to download. + %d soubor(y) se nepodařilo stáhnout. forward confirmation reason %d file(s) were deleted. + %d soubor(y) smazán(y). forward confirmation reason %d file(s) were not downloaded. + %d soubor(y) nestažen(y). forward confirmation reason @@ -191,6 +187,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d přeskočené zprávy @@ -232,14 +232,17 @@ %lld messages blocked + %lld zprávy blokovaný No comment provided by engineer. %lld messages blocked by admin + %lld zprávy blokovaný adminem No comment provided by engineer. %lld messages marked deleted + %lld zprávy označeno jako smazáno No comment provided by engineer. @@ -256,11 +259,6 @@ %d nové jazyky rozhraní No comment provided by engineer. - - %lld second(s) - %lld vteřin - No comment provided by engineer. - %lld seconds %lld vteřin @@ -311,22 +309,13 @@ %u zpráv přeskočeno. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) No comment provided by engineer. (this device v%@) - No comment provided by engineer. - - - ) - ) + (toto zařízení v%@) No comment provided by engineer. @@ -389,11 +378,6 @@ \*tučně* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -427,11 +411,6 @@ - historie úprav. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec time to disappear @@ -444,7 +423,8 @@ 1 day 1 den - time interval + delete after time +time interval 1 hour @@ -459,12 +439,18 @@ 1 month 1 měsíc - time interval + delete after time +time interval 1 week 1 týden - time interval + delete after time +time interval + + + 1 year + delete after time 1-time link @@ -489,11 +475,6 @@ 30 vteřin No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -560,8 +541,16 @@ Accept Přijmout accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions @@ -581,7 +570,11 @@ Accept incognito Přijmout inkognito accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions @@ -595,6 +588,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -608,6 +605,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Přidat profil @@ -632,6 +633,10 @@ Přidat do jiného zařízení No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Přidat uvítací zprávu @@ -697,6 +702,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Všechna data aplikace jsou smazána. @@ -707,6 +716,10 @@ Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. Všechna data se při zadání vymažou. @@ -742,6 +755,14 @@ All profiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Všechny vaše kontakty zůstanou připojeny. @@ -813,6 +834,10 @@ Povolit nevratné smazání odeslaných zpráv. (24 hodin) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. No comment provided by engineer. @@ -889,6 +914,10 @@ Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle. No comment provided by engineer. + + Another reason + report reason + Answer call Přijmout hovor @@ -913,6 +942,10 @@ Aplikace šifruje nové místní soubory (s výjimkou videí). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Ikona aplikace @@ -955,6 +988,18 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -963,6 +1008,18 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1066,6 +1123,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1083,6 +1144,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -1169,11 +1234,21 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Hovor již skončil! @@ -1218,7 +1293,7 @@ Cancel Zrušit alert action - alert button +alert button Cancel migration @@ -1251,6 +1326,10 @@ Změnit No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1299,7 +1378,7 @@ Change self-destruct passcode Změnit sebedestrukční heslo authentication reason - set passcode view +set passcode view Chat @@ -1389,11 +1468,23 @@ Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Chaty No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1453,6 +1544,14 @@ Vyčistit konverzaci? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1470,6 +1569,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Porovnat soubor @@ -1500,14 +1603,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1525,6 +1620,10 @@ Konfigurace serverů ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Potvrdit @@ -1570,6 +1669,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect Připojit @@ -1671,6 +1774,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Chyba připojení @@ -1681,6 +1788,15 @@ This is your own one-time link! Chyba spojení (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1690,6 +1806,10 @@ This is your own one-time link! Požadavek na připojení byl odeslán! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1763,6 +1883,10 @@ This is your own one-time link! Kontakty mohou označit zprávy ke smazání; vy je budete moci zobrazit. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Pokračovat @@ -1831,6 +1955,10 @@ This is your own one-time link! Vytvořit odkaz No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻 @@ -1838,6 +1966,7 @@ This is your own one-time link! Create profile + Vytvořte si profil No comment provided by engineer. @@ -2036,8 +2165,7 @@ This is your own one-time link! Delete Smazat alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2075,6 +2203,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Smazat chat profil @@ -2085,6 +2217,10 @@ This is your own one-time link! Smazat chat profil? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2162,6 +2298,10 @@ This is your own one-time link! Smazat odkaz? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Smazat zprávu člena? @@ -2175,7 +2315,7 @@ This is your own one-time link! Delete messages Smazat zprávy - No comment provided by engineer. + alert button Delete messages after @@ -2211,6 +2351,10 @@ This is your own one-time link! Odstranit frontu server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2356,6 +2500,14 @@ This is your own one-time link! Vypnutí zámku SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Vypnout pro všechny @@ -2439,6 +2591,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Nevytvářet adresu @@ -2449,9 +2605,17 @@ This is your own one-time link! Nepovolovat No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Znovu neukazuj + alert action + + + Done No comment provided by engineer. @@ -2462,7 +2626,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2531,8 +2695,8 @@ This is your own one-time link! Povolit (zachovat přepsání) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2548,7 +2712,7 @@ This is your own one-time link! Enable automatic message deletion? Povolit automatické mazání zpráv? - No comment provided by engineer. + alert title Enable camera access @@ -2668,6 +2832,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Zadat heslo @@ -2738,6 +2906,10 @@ This is your own one-time link! Chyba při přijímání žádosti o kontakt No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Chyba přidávání člena(ů) @@ -2770,6 +2942,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -2789,6 +2965,10 @@ This is your own one-time link! Chyba při vytváření odkazu skupiny No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Chyba vytvoření kontaktu člena @@ -2803,6 +2983,10 @@ This is your own one-time link! Chyba při vytváření profilu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Chyba dešifrování souboru @@ -2813,6 +2997,10 @@ This is your own one-time link! Chyba při mazání databáze chatu No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Chyba při mazání chatu! @@ -2906,10 +3094,18 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Chyba při odebrání člena - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -2920,6 +3116,10 @@ This is your own one-time link! Chyba při ukládání serverů ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Chyba při ukládání profilu skupiny @@ -2996,6 +3196,10 @@ This is your own one-time link! Chyba synchronizace připojení No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Chyba aktualizace odkazu skupiny @@ -3036,7 +3240,9 @@ This is your own one-time link! Error: %@ Chyba: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3070,6 +3276,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database Export databáze @@ -3108,24 +3318,41 @@ This is your own one-time link! Rychle a bez čekání, než bude odesílatel online! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Oblíbené swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3243,6 +3470,10 @@ This is your own one-time link! Opravit nepodporované členem skupiny No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3256,6 +3487,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -3360,6 +3595,10 @@ Error: %2$@ GIFy a nálepky No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3461,11 +3700,19 @@ Error: %2$@ Skupina bude smazána pro vás - toto nelze vzít zpět! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Pomoc No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Skryté @@ -3523,6 +3770,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Jak @@ -3655,6 +3906,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Inkognito @@ -3744,6 +4003,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -3995,6 +4274,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Živé zprávy! @@ -4078,10 +4369,18 @@ This is your link for group %@! Člen No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4105,6 +4404,10 @@ This is your link for group %@! Člen bude odstraněn ze skupiny - toto nelze vzít zpět! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Členové skupin mohou přidávat reakce na zprávy. @@ -4115,6 +4418,10 @@ This is your link for group %@! Členové skupiny mohou nevratně mazat odeslané zprávy. (24 hodin) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -4139,6 +4446,10 @@ This is your link for group %@! Členové skupiny mohou posílat hlasové zprávy. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4236,6 +4547,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4324,6 +4639,10 @@ This is your link for group %@! Upraveno v: %@ copied message info + + More + swipe action + More improvements are coming soon! Další vylepšení se chystají již brzy! @@ -4350,7 +4669,11 @@ This is your link for group %@! Mute Ztlumit - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4397,6 +4720,10 @@ This is your link for group %@! Stav sítě No comment provided by engineer. + + New + token status text + New Passcode Nové heslo @@ -4456,6 +4783,10 @@ This is your link for group %@! Nová role člena No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Nová zpráva @@ -4480,6 +4811,22 @@ This is your link for group %@! Žádné heslo aplikace Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Nebyl vybrán žádný kontakt @@ -4527,6 +4874,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -4574,6 +4925,14 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. Bez uživatelských identifikátorů @@ -4583,6 +4942,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4601,10 +4964,18 @@ This is your link for group %@! Oznámení jsou zakázána! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4626,7 +4997,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -4685,6 +5057,14 @@ Vyžaduje povolení sítě VPN. Pouze majitelé skupin mohou povolit zasílání hlasových zpráv. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Reakce na zprávy můžete přidávat pouze vy. @@ -4738,7 +5118,7 @@ Vyžaduje povolení sítě VPN. Open Otevřít - No comment provided by engineer. + alert action Open Settings @@ -4767,6 +5147,10 @@ Vyžaduje povolení sítě VPN. Open group No comment provided by engineer. + + Open link? + alert title + Open migration to another device authentication reason @@ -4807,6 +5191,10 @@ Vyžaduje povolení sítě VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -4981,6 +5369,22 @@ Error: %@ Heslo uložte bezpečně, v případě jeho ztráty jej NEBUDE možné změnit. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Polské rozhraní @@ -5027,16 +5431,28 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Nové vymezení soukromí No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Soukromé názvy souborů No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -5105,6 +5521,10 @@ Error: %@ Zakázat reakce na zprávy. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5340,11 +5760,23 @@ Enable in *Network & servers* settings. Snížení spotřeby baterie No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Odmítnout reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5356,6 +5788,10 @@ Enable in *Network & servers* settings. Odmítnout žádost o kontakt No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Přenosový server se používá pouze v případě potřeby. Jiná strana může sledovat vaši IP adresu. @@ -5434,6 +5870,50 @@ Enable in *Network & servers* settings. Odpověď chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required Povinné @@ -5517,9 +5997,13 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5566,13 +6050,17 @@ Enable in *Network & servers* settings. Save Uložit alert button - chat item action +chat item action Save (and notify contacts) Uložit (a informovat kontakty) alert button + + Save admission settings? + alert title + Save and notify contact Uložit a upozornit kontakt @@ -5597,6 +6085,10 @@ Enable in *Network & servers* settings. Uložení profilu skupiny No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Uložte heslo a otevřete chat @@ -5818,6 +6310,10 @@ Enable in *Network & servers* settings. Odeslat oznámení No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Zasílání otázek a nápadů @@ -6013,6 +6509,10 @@ Enable in *Network & servers* settings. Nastavit 1 den No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Nastavení jména kontaktu… @@ -6032,6 +6532,14 @@ Enable in *Network & servers* settings. Nastavte jej namísto ověřování systému. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Nastavit heslo @@ -6073,7 +6581,7 @@ Enable in *Network & servers* settings. Share Sdílet alert action - chat item action +chat item action Share 1-time link @@ -6128,6 +6636,10 @@ Enable in *Network & servers* settings. Sdílet s kontakty No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6220,6 +6732,10 @@ Enable in *Network & servers* settings. SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX kontaktní adresa @@ -6312,6 +6828,11 @@ Enable in *Network & servers* settings. Někdo notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6391,6 +6912,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6444,6 +6969,10 @@ Enable in *Network & servers* settings. Časový limit připojení TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6506,13 +7035,17 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. Test selhal v kroku %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Testovací server @@ -6629,19 +7162,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Stará databáze nebyla během přenášení odstraněna, lze ji smazat. No comment provided by engineer. - - The profile is only shared with your contacts. - Profil je sdílen pouze s vašimi kontakty. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -6701,6 +7225,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Tuto akci nelze vzít zpět - zprávy odeslané a přijaté dříve, než bylo zvoleno, budou smazány. Může to trvat několik minut. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tuto akci nelze vzít zpět - váš profil, kontakty, zprávy a soubory budou nenávratně ztraceny. @@ -6740,10 +7268,18 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován This is your own one-time link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Toto nastavení platí pro zprávy ve vašem aktuálním chat profilu **%@**. @@ -6846,6 +7382,10 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Změnit inkognito režim při připojení. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -6998,13 +7538,17 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Unmute Zrušit ztlumení - swipe action + notification label action Unread Nepřečtený swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -7028,6 +7572,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Aktualizací nastavení se klient znovu připojí ke všem serverům. @@ -7081,6 +7629,14 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Používat servery SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Použijte chat @@ -7139,6 +7695,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. @@ -7147,6 +7707,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7478,10 +8042,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -7565,6 +8125,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Nemůžete posílat zprávy! @@ -7641,6 +8205,10 @@ Repeat connection request? Odeslali jste pozvánku do skupiny No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Ke skupině budete připojeni, až bude zařízení hostitele skupiny online, vyčkejte prosím nebo se podívejte později! @@ -7703,11 +8271,6 @@ Repeat connection request? Vaše servery ICE No comment provided by engineer. - - Your SMP servers - Vaše servery SMP - No comment provided by engineer. - Your SimpleX address Vaše SimpleX adresa @@ -7789,6 +8352,11 @@ Repeat connection request? Váš profil **%@** bude sdílen. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Profil je sdílen pouze s vašimi kontakty. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty. Servery SimpleX nevidí váš profil. @@ -7798,11 +8366,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení. - No comment provided by engineer. - Your random profile Váš náhodný profil @@ -7852,6 +8415,10 @@ Repeat connection request? výše, pak vyberte: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call přijatý hovor @@ -7861,6 +8428,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin správce @@ -7880,6 +8451,10 @@ Repeat connection request? povoluji šifrování… chat item text + + all + member criteria value + all members feature role @@ -7893,6 +8468,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -7926,7 +8505,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -7952,6 +8532,10 @@ Repeat connection request? volání… call status + + can't send messages + No comment provided by engineer. + cancelled %@ zrušeno %@ @@ -8056,6 +8640,14 @@ Repeat connection request? contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kontakt má šifrování e2e @@ -8066,6 +8658,10 @@ Repeat connection request? kontakt nemá šifrování e2e No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator tvůrce @@ -8093,7 +8689,8 @@ Repeat connection request? default (%@) výchozí (%@) - pref value + delete after time +pref value default (no) @@ -8218,18 +8815,10 @@ Repeat connection request? chyba No comment provided by engineer. - - event happened - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8239,6 +8828,10 @@ Repeat connection request? skupina smazána No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated profil skupiny aktualizován @@ -8361,6 +8954,10 @@ Repeat connection request? připojeno rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8390,19 +8987,19 @@ Repeat connection request? moderovaný %@ marked deleted chat item preview text + + moderator + member role + months měsíců time unit - - mute - No comment provided by engineer. - never nikdy - No comment provided by engineer. + delete after time new message @@ -8424,6 +9021,10 @@ Repeat connection request? žádný text copied message info in history + + not synchronized + No comment provided by engineer. + observer pozorovatel @@ -8433,8 +9034,9 @@ Repeat connection request? off vypnuto enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -8473,6 +9075,18 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8487,6 +9101,10 @@ Repeat connection request? obdržel potvrzení… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call odmítnutý hovor @@ -8506,6 +9124,10 @@ Repeat connection request? removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -8515,10 +9137,22 @@ Repeat connection request? odstranil vás rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -8606,10 +9240,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. @@ -8694,6 +9324,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group jste pozváni do skupiny @@ -8766,7 +9400,7 @@ last received msg: %2$@
- +
@@ -8802,7 +9436,7 @@ last received msg: %2$@
- +
@@ -8824,13 +9458,17 @@ last received msg: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -8843,15 +9481,11 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -8870,7 +9504,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/contents.json b/apps/ios/SimpleX Localizations/cs.xcloc/contents.json index aaa2ed1ee0..9cd5922c24 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/cs.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "cs", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index e993740f1c..b2e404c141 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (kann kopiert werden) @@ -202,6 +190,11 @@ %d s time interval + + %d seconds(s) + %d Sekunde(n) + delete after time + %d skipped message(s) %d übersprungene Nachricht(en) @@ -272,11 +265,6 @@ %lld neue Sprachen für die Bedienoberfläche No comment provided by engineer. - - %lld second(s) - %lld Sekunde(n) - No comment provided by engineer. - %lld seconds %lld Sekunden @@ -327,11 +315,6 @@ %u übersprungene Nachrichten. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (Neu) @@ -342,11 +325,6 @@ (Dieses Gerät hat v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Kontakt hinzufügen**: Um einen neuen Einladungslink zu erstellen. @@ -412,11 +390,6 @@ \*fett* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -453,11 +426,6 @@ - Nachrichtenverlauf bearbeiten No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sek @@ -470,8 +438,9 @@ 1 day - täglich - time interval + Älter als ein Tag + delete after time +time interval 1 hour @@ -485,13 +454,20 @@ 1 month - monatlich - time interval + Älter als ein Monat + delete after time +time interval 1 week - wöchentlich - time interval + Älter als eine Woche + delete after time +time interval + + + 1 year + Älter als ein Jahr + delete after time 1-time link @@ -518,11 +494,6 @@ 30 Sekunden No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -579,7 +550,7 @@ About operators - Über Betreiber + Über die Betreiber No comment provided by engineer. @@ -591,8 +562,18 @@ Accept Annehmen accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + Als Mitglied übernehmen + alert action + + + Accept as observer + Als Beobachter übernehmen + alert action Accept conditions @@ -613,7 +594,12 @@ Accept incognito Inkognito akzeptieren accept contact request via notification - swipe action +swipe action + + + Accept member + Mitglied übernehmen + alert title Accepted conditions @@ -630,6 +616,11 @@ Fehler bei der Bestätigung No comment provided by engineer. + + Active + Aktiv + token status text + Active connections Aktive Verbindungen @@ -645,6 +636,11 @@ Freunde aufnehmen No comment provided by engineer. + + Add list + Liste hinzufügen + No comment provided by engineer. + Add profile Profil hinzufügen @@ -652,12 +648,12 @@ Add server - Füge Server hinzu + Server hinzufügen No comment provided by engineer. Add servers by scanning QR codes. - Fügen Sie Server durch Scannen der QR Codes hinzu. + Server durch Scannen von QR Codes hinzufügen. No comment provided by engineer. @@ -670,6 +666,11 @@ Einem anderen Gerät hinzufügen No comment provided by engineer. + + Add to list + Zur Liste hinzufügen + No comment provided by engineer. + Add welcome message Begrüßungsmeldung hinzufügen @@ -745,6 +746,11 @@ Erweiterte Einstellungen No comment provided by engineer. + + All + Alle + No comment provided by engineer. + All app data is deleted. Werden die App-Daten komplett gelöscht. @@ -755,6 +761,11 @@ Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Alle Chats werden von der Liste %@ entfernt und danach wird die Liste gelöscht. + alert message + All data is erased when it is entered. Alle Daten werden gelöscht, sobald dieser eingegeben wird. @@ -795,6 +806,16 @@ Alle Profile profile dropdown + + All reports will be archived for you. + Alle Meldungen werden für Sie archiviert. + No comment provided by engineer. + + + All servers + Alle Server + No comment provided by engineer. + All your contacts will remain connected. Alle Ihre Kontakte bleiben verbunden. @@ -870,6 +891,11 @@ Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden) No comment provided by engineer. + + Allow to report messsages to moderators. + Melden von Nachrichten an Moderatoren erlauben. + No comment provided by engineer. + Allow to send SimpleX links. Das Senden von SimpleX-Links erlauben. @@ -950,6 +976,11 @@ Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt. No comment provided by engineer. + + Another reason + Anderer Grund + report reason + Answer call Anruf annehmen @@ -975,6 +1006,11 @@ Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt. No comment provided by engineer. + + App group: + App-Gruppe: + No comment provided by engineer. + App icon App-Icon @@ -1020,6 +1056,21 @@ Anwenden auf No comment provided by engineer. + + Archive + Archiv + No comment provided by engineer. + + + Archive %lld reports? + Archiviere %lld Meldungen? + No comment provided by engineer. + + + Archive all reports? + Alle Meldungen archivieren? + No comment provided by engineer. + Archive and upload Archivieren und Hochladen @@ -1030,6 +1081,21 @@ Kontakte für spätere Chats archivieren. No comment provided by engineer. + + Archive report + Meldung archivieren + No comment provided by engineer. + + + Archive report? + Meldung archivieren? + No comment provided by engineer. + + + Archive reports + Meldungen archivieren + swipe action + Archived contacts Archivierte Kontakte @@ -1140,6 +1206,11 @@ Bessere Gruppen No comment provided by engineer. + + Better groups performance + Bessere Leistung von Gruppen + No comment provided by engineer. + Better message dates. Verbesserte Nachrichten-Datumsinformation @@ -1160,6 +1231,11 @@ Verbesserte Benachrichtigungen No comment provided by engineer. + + Better privacy and security + Bessere(r) Security und Datenschutz + No comment provided by engineer. + Better security ✅ Verbesserte Sicherheit ✅ @@ -1217,7 +1293,7 @@ Blur media - Medium unscharf machen + Medium verpixeln No comment provided by engineer. @@ -1260,11 +1336,25 @@ Geschäftliche Chats No comment provided by engineer. + + Businesses + Unternehmen + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden: +- nur legale Inhalte in öffentlichen Gruppen zu versenden. +- andere Nutzer zu respektieren - kein Spam. + No comment provided by engineer. + Call already ended! Anruf ist bereits beendet! @@ -1314,7 +1404,7 @@ Cancel Abbrechen alert action - alert button +alert button Cancel migration @@ -1351,6 +1441,11 @@ Ändern No comment provided by engineer. + + Change automatic message deletion? + Automatisches Löschen von Nachrichten ändern? + alert title + Change chat profiles Chat-Profile wechseln @@ -1400,7 +1495,7 @@ Change self-destruct passcode Selbstzerstörungs-Zugangscode ändern authentication reason - set passcode view +set passcode view Chat @@ -1502,11 +1597,26 @@ Der Chat wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + Chat with admins + Chat mit Administratoren + chat toolbar + + + Chat with member + Chat mit einem Mitglied + No comment provided by engineer. + Chats Chats No comment provided by engineer. + + Chats with members + Chats mit Mitgliedern + No comment provided by engineer. + Check messages every 20 min. Alle 20min Nachrichten überprüfen. @@ -1572,6 +1682,16 @@ Chat-Inhalte entfernen? No comment provided by engineer. + + Clear group? + Gruppe entfernen? + No comment provided by engineer. + + + Clear or delete group? + Gruppe entfernen oder löschen? + No comment provided by engineer. + Clear private notes? Private Notizen entfernen? @@ -1592,6 +1712,11 @@ Farbvariante No comment provided by engineer. + + Community guidelines violation + Verstoß gegen die Gemeinschaftsrichtlinien + report reason + Compare file Datei vergleichen @@ -1627,16 +1752,6 @@ Nutzungsbedingungen No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Die Nutzungsbedingungen der aktivierten Betreiber werden nach 30 Tagen akzeptiert. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**. @@ -1657,6 +1772,11 @@ ICE-Server konfigurieren No comment provided by engineer. + + Configure server operators + Server-Betreiber konfigurieren + No comment provided by engineer. + Confirm Bestätigen @@ -1707,6 +1827,11 @@ Hochladen bestätigen No comment provided by engineer. + + Confirmed + Bestätigt + token status text + Connect Verbinden @@ -1826,6 +1951,11 @@ Das ist Ihr eigener Einmal-Link! Verbindungs- und Server-Status. No comment provided by engineer. + + Connection blocked + Verbindung blockiert + No comment provided by engineer. + Connection error Verbindungsfehler @@ -1836,6 +1966,18 @@ Das ist Ihr eigener Einmal-Link! Verbindungsfehler (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + Die Verbindung wurde vom Server-Betreiber blockiert: +%@ + No comment provided by engineer. + + + Connection not ready. + Verbindung noch nicht bereit. + No comment provided by engineer. + Connection notifications Verbindungsbenachrichtigungen @@ -1846,6 +1988,11 @@ Das ist Ihr eigener Einmal-Link! Verbindungsanfrage wurde gesendet! No comment provided by engineer. + + Connection requires encryption renegotiation. + Die Verbindung erfordert eine Neuverhandlung der Verschlüsselung. + No comment provided by engineer. + Connection security Verbindungs-Sicherheit @@ -1926,6 +2073,11 @@ Das ist Ihr eigener Einmal-Link! Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen. No comment provided by engineer. + + Content violates conditions of use + Inhalt verletzt Nutzungsbedingungen + blocking reason + Continue Weiter @@ -2001,6 +2153,11 @@ Das ist Ihr eigener Einmal-Link! Link erzeugen No comment provided by engineer. + + Create list + Liste erstellen + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻 @@ -2068,7 +2225,7 @@ Das ist Ihr eigener Einmal-Link! Current profile - Aktueller Profil + Aktuelles Profil No comment provided by engineer. @@ -2218,8 +2375,7 @@ Das ist Ihr eigener Einmal-Link! Delete Löschen alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2261,6 +2417,11 @@ Das ist Ihr eigener Einmal-Link! Chat löschen No comment provided by engineer. + + Delete chat messages from your device. + Chat-Nachrichten von Ihrem Gerät löschen. + No comment provided by engineer. + Delete chat profile Chat-Profil löschen @@ -2271,6 +2432,11 @@ Das ist Ihr eigener Einmal-Link! Chat-Profil löschen? No comment provided by engineer. + + Delete chat with member? + Chat mit dem Mitglied löschen? + alert title + Delete chat? Chat löschen? @@ -2351,6 +2517,11 @@ Das ist Ihr eigener Einmal-Link! Link löschen? No comment provided by engineer. + + Delete list? + Liste löschen? + alert title + Delete member message? Nachricht des Mitglieds löschen? @@ -2364,11 +2535,11 @@ Das ist Ihr eigener Einmal-Link! Delete messages Nachrichten löschen - No comment provided by engineer. + alert button Delete messages after - Löschen der Nachrichten + Nachrichten löschen No comment provided by engineer. @@ -2401,6 +2572,11 @@ Das ist Ihr eigener Einmal-Link! Lösche Warteschlange server test step + + Delete report + Meldung löschen + No comment provided by engineer. + Delete up to 20 messages at once. Löschen Sie bis zu 20 Nachrichten auf einmal. @@ -2561,6 +2737,16 @@ Das ist Ihr eigener Einmal-Link! SimpleX-Sperre deaktivieren authentication reason + + Disable automatic message deletion? + Automatisches Löschen von Nachrichten deaktivieren? + alert title + + + Disable delete messages + Löschen von Nachrichten deaktivieren + alert button + Disable for all Für Alle deaktivieren @@ -2651,6 +2837,11 @@ Das ist Ihr eigener Einmal-Link! Verwenden Sie keine Anmeldeinformationen mit einem Proxy. No comment provided by engineer. + + Documents: + Dokumente: + No comment provided by engineer. + Don't create address Keine Adresse erstellt @@ -2661,9 +2852,19 @@ Das ist Ihr eigener Einmal-Link! Nicht aktivieren No comment provided by engineer. + + Don't miss important messages. + Verpassen Sie keine wichtigen Nachrichten. + No comment provided by engineer. + Don't show again Nicht nochmals anzeigen + alert action + + + Done + Fertig No comment provided by engineer. @@ -2675,7 +2876,7 @@ Das ist Ihr eigener Einmal-Link! Download Herunterladen alert button - chat item action +chat item action Download errors @@ -2752,9 +2953,9 @@ Das ist Ihr eigener Einmal-Link! Aktivieren (vorgenommene Einstellungen bleiben erhalten) No comment provided by engineer. - - Enable Flux - Flux aktivieren + + Enable Flux in Network & servers settings for better metadata privacy. + Für einen besseren Metadatenschutz Flux in den Netzwerk- und Servereinstellungen aktivieren. No comment provided by engineer. @@ -2770,7 +2971,7 @@ Das ist Ihr eigener Einmal-Link! Enable automatic message deletion? Automatisches Löschen von Nachrichten aktivieren? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3098,11 @@ Das ist Ihr eigener Einmal-Link! Neuverhandlung der Verschlüsselung fehlgeschlagen. No comment provided by engineer. + + Encryption renegotiation in progress. + Die Neuverhandlung der Verschlüsselung läuft. + No comment provided by engineer. + Enter Passcode Zugangscode eingeben @@ -2972,6 +3178,11 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Annehmen der Kontaktanfrage No comment provided by engineer. + + Error accepting member + Fehler beim Übernehmen des Mitglieds + alert title + Error adding member(s) Fehler beim Hinzufügen von Mitgliedern @@ -3007,6 +3218,11 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Wechseln zum Inkognito-Profil! No comment provided by engineer. + + Error checking token status + Fehler beim Überprüfen des Token-Status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut. @@ -3027,6 +3243,11 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Erzeugen des Gruppen-Links No comment provided by engineer. + + Error creating list + Fehler beim Erstellen der Liste + alert title + Error creating member contact Fehler beim Anlegen eines Mitglied-Kontaktes @@ -3042,6 +3263,11 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Erstellen des Profils! No comment provided by engineer. + + Error creating report + Fehler beim Erstellen der Meldung + No comment provided by engineer. + Error decrypting file Fehler beim Entschlüsseln der Datei @@ -3052,6 +3278,11 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Löschen der Chat-Datenbank No comment provided by engineer. + + Error deleting chat with member + Fehler beim Löschen des Chats mit dem Mitglied + alert title + Error deleting chat! Fehler beim Löschen des Chats! @@ -3139,7 +3370,7 @@ Das ist Ihr eigener Einmal-Link! Error receiving file - Fehler beim Empfangen der Datei + Fehler beim Herunterladen der Datei alert title @@ -3152,10 +3383,20 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Wiederherstellen der Verbindungen zu den Servern No comment provided by engineer. + + Error registering for notifications + Fehler beim Registrieren für Benachrichtigungen + alert title + Error removing member Fehler beim Entfernen des Mitglieds - No comment provided by engineer. + alert title + + + Error reordering lists + Fehler beim Umsortieren der Listen + alert title Error resetting statistics @@ -3167,6 +3408,11 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Speichern der ICE-Server No comment provided by engineer. + + Error saving chat list + Fehler beim Speichern der Chat-Liste + alert title + Error saving group profile Fehler beim Speichern des Gruppenprofils @@ -3247,6 +3493,11 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Synchronisieren der Verbindung No comment provided by engineer. + + Error testing server connection + Fehler beim Testen der Server-Verbindung + No comment provided by engineer. + Error updating group link Fehler beim Aktualisieren des Gruppen-Links @@ -3290,7 +3541,9 @@ Das ist Ihr eigener Einmal-Link! Error: %@ Fehler: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3327,6 +3580,11 @@ Das ist Ihr eigener Einmal-Link! Erweitern chat item action + + Expired + Abgelaufen + token status text + Export database Datenbank exportieren @@ -3367,20 +3625,35 @@ Das ist Ihr eigener Einmal-Link! Schnell und ohne warten auf den Absender, bis er online ist! No comment provided by engineer. + + Faster deletion of groups. + Schnelleres löschen von Gruppen. + No comment provided by engineer. + Faster joining and more reliable messages. Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung. No comment provided by engineer. + + Faster sending messages. + Schnelleres versenden von Nachrichten. + No comment provided by engineer. + Favorite Favorit swipe action + + Favorites + Favoriten + No comment provided by engineer. + File error Datei-Fehler - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3662,13 @@ Das ist Ihr eigener Einmal-Link! %@ alert message + + File is blocked by server operator: +%@. + Datei wurde vom Server-Betreiber blockiert: +%@. + file error text + File not found - most likely file was deleted or cancelled. Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen. @@ -3416,12 +3696,12 @@ Das ist Ihr eigener Einmal-Link! File will be received when your contact completes uploading it. - Die Datei wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Die Datei wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. No comment provided by engineer. File will be received when your contact is online, please wait or check later! - Die Datei wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! + Die Datei wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. @@ -3514,6 +3794,11 @@ Das ist Ihr eigener Einmal-Link! Reparatur wird vom Gruppenmitglied nicht unterstützt No comment provided by engineer. + + For all moderators + Für alle Moderatoren + No comment provided by engineer. + For chat profile %@: Für das Chat-Profil %@: @@ -3529,6 +3814,11 @@ Das ist Ihr eigener Einmal-Link! Wenn Ihr Kontakt beispielsweise Nachrichten über einen SimpleX-Chatserver empfängt, wird Ihre App diese über einen der Server von Flux versenden. No comment provided by engineer. + + For me + Für mich + No comment provided by engineer. + For private routing Für privates Routing @@ -3653,6 +3943,11 @@ Fehler: %2$@ GIFs und Sticker No comment provided by engineer. + + Get notified when mentioned. + Bei Erwähnung benachrichtigt werden. + No comment provided by engineer. + Good afternoon! Guten Nachmittag! @@ -3758,11 +4053,21 @@ Fehler: %2$@ Die Gruppe wird nur bei Ihnen gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + Groups + Gruppen + No comment provided by engineer. + Help Hilfe No comment provided by engineer. + + Help admins moderating their groups. + Helfen Sie Administratoren bei der Moderation ihrer Gruppen. + No comment provided by engineer. + Hidden Verborgen @@ -3823,6 +4128,11 @@ Fehler: %2$@ Wie es die Privatsphäre schützt No comment provided by engineer. + + How it works + Wie es funktioniert + alert button + How to Anleitung @@ -3880,12 +4190,12 @@ Fehler: %2$@ Image will be received when your contact completes uploading it. - Das Bild wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Das Bild wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. No comment provided by engineer. Image will be received when your contact is online, please wait or check later! - Das Bild wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! + Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. @@ -3965,6 +4275,16 @@ Weitere Verbesserungen sind bald verfügbar! Klingeltöne No comment provided by engineer. + + Inappropriate content + Unangemessener Inhalt + report reason + + + Inappropriate profile + Unangemessenes Profil + report reason + Incognito Inkognito @@ -4057,6 +4377,31 @@ Weitere Verbesserungen sind bald verfügbar! Interface-Farben No comment provided by engineer. + + Invalid + Ungültig + token status text + + + Invalid (bad token) + Ungültig (falsches Token) + token status text + + + Invalid (expired) + Ungültig (abgelaufen) + token status text + + + Invalid (unregistered) + Ungültig (nicht registriert) + token status text + + + Invalid (wrong topic) + Ungültig (falsches Thema) + token status text + Invalid QR code Ungültiger QR-Code @@ -4330,6 +4675,21 @@ Das ist Ihr Link für die Gruppe %@! Verknüpfte Desktops No comment provided by engineer. + + List + Liste + swipe action + + + List name and emoji should be different for all lists. + Der Listenname und das Emoji sollen für alle Listen unterschiedlich sein. + No comment provided by engineer. + + + List name... + Listenname... + No comment provided by engineer. + Live message! Live Nachricht! @@ -4415,11 +4775,21 @@ Das ist Ihr Link für die Gruppe %@! Mitglied No comment provided by engineer. + + Member admission + Aufnahme von Mitgliedern + No comment provided by engineer. + Member inactive Mitglied inaktiv item status text + + Member reports + Mitglieder-Meldungen + chat feature + Member role will be changed to "%@". All chat members will be notified. Die Rolle des Mitglieds wird auf "%@" geändert. Alle Chat-Mitglieder werden darüber informiert. @@ -4445,6 +4815,11 @@ Das ist Ihr Link für die Gruppe %@! Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + Member will join the group, accept member? + Ein Mitglied wird der Gruppe beitreten. Übernehmen? + alert message + Members can add message reactions. Gruppenmitglieder können eine Reaktion auf Nachrichten geben. @@ -4455,9 +4830,14 @@ Das ist Ihr Link für die Gruppe %@! Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden) No comment provided by engineer. + + Members can report messsages to moderators. + Mitglieder können Nachrichten an Moderatoren melden. + No comment provided by engineer. + Members can send SimpleX links. - Gruppenmitglieder können SimpleX-Links senden. + Gruppenmitglieder können SimpleX-Links versenden. No comment provided by engineer. @@ -4467,12 +4847,12 @@ Das ist Ihr Link für die Gruppe %@! Members can send disappearing messages. - Gruppenmitglieder können verschwindende Nachrichten senden. + Gruppenmitglieder können verschwindende Nachrichten versenden. No comment provided by engineer. Members can send files and media. - Gruppenmitglieder können Dateien und Medien senden. + Gruppenmitglieder können Dateien und Medien versenden. No comment provided by engineer. @@ -4480,6 +4860,11 @@ Das ist Ihr Link für die Gruppe %@! Gruppenmitglieder können Sprachnachrichten versenden. No comment provided by engineer. + + Mention members 👋 + Erwähnung von Mitgliedern 👋 + No comment provided by engineer. + Menus Menüs @@ -4590,6 +4975,11 @@ Das ist Ihr Link für die Gruppe %@! Die Nachrichten von %@ werden angezeigt! No comment provided by engineer. + + Messages in this chat will never be deleted. + Nachrichten in diesem Chat werden nie gelöscht. + alert message + Messages received Empfangene Nachrichten @@ -4607,12 +4997,12 @@ Das ist Ihr Link für die Gruppe %@! Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery. - Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt. + Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt. No comment provided by engineer. Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. - Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt. + Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt. No comment provided by engineer. @@ -4690,6 +5080,11 @@ Das ist Ihr Link für die Gruppe %@! Moderiert um: %@ copied message info + + More + Mehr + swipe action + More improvements are coming soon! Weitere Verbesserungen sind bald verfügbar! @@ -4718,7 +5113,12 @@ Das ist Ihr Link für die Gruppe %@! Mute Stummschalten - swipe action + notification label action + + + Mute all + Alle stummschalten + notification label action Muted when inactive! @@ -4770,6 +5170,11 @@ Das ist Ihr Link für die Gruppe %@! Netzwerkstatus No comment provided by engineer. + + New + Neu + token status text + New Passcode Neuer Zugangscode @@ -4835,6 +5240,11 @@ Das ist Ihr Link für die Gruppe %@! Neue Mitgliedsrolle No comment provided by engineer. + + New member wants to join the group. + Ein neues Mitglied will der Gruppe beitreten. + rcv group event chat item + New message Neue Nachricht @@ -4860,6 +5270,26 @@ Das ist Ihr Link für die Gruppe %@! Kein App-Passwort Authentication unavailable + + No chats + Keine Chats + No comment provided by engineer. + + + No chats found + Keine Chats gefunden + No comment provided by engineer. + + + No chats in list %@ + Keine Chats in der Liste %@ + No comment provided by engineer. + + + No chats with members + Keine Chats mit Mitgliedern + No comment provided by engineer. + No contacts selected Keine Kontakte ausgewählt @@ -4910,6 +5340,11 @@ Das ist Ihr Link für die Gruppe %@! Keine Medien- und Dateiserver. servers error + + No message + Keine Nachricht + No comment provided by engineer. + No message servers. Keine Nachrichten-Server. @@ -4942,7 +5377,7 @@ Das ist Ihr Link für die Gruppe %@! No received or sent files - Keine empfangenen oder gesendeten Dateien + Keine herunter- oder hochgeladenen Dateien No comment provided by engineer. @@ -4952,7 +5387,7 @@ Das ist Ihr Link für die Gruppe %@! No servers to receive files. - Keine Server für den Empfang von Dateien. + Keine Server für das Herunterladen von Dateien. servers error @@ -4965,6 +5400,16 @@ Das ist Ihr Link für die Gruppe %@! Keine Server für das Versenden von Dateien. servers error + + No token! + Kein Token! + alert title + + + No unread chats + Keine ungelesenen Chats + No comment provided by engineer. + No user identifiers. Keine Benutzerkennungen. @@ -4975,6 +5420,11 @@ Das ist Ihr Link für die Gruppe %@! Nicht kompatibel! No comment provided by engineer. + + Notes + Anmerkungen + No comment provided by engineer. + Nothing selected Nichts ausgewählt @@ -4995,11 +5445,21 @@ Das ist Ihr Link für die Gruppe %@! Benachrichtigungen sind deaktiviert! No comment provided by engineer. + + Notifications error + Benachrichtigungs-Fehler + alert title + Notifications privacy Datenschutz für Benachrichtigungen No comment provided by engineer. + + Notifications status + Benachrichtigungs-Status + alert title + Now admins can: - delete members' messages. @@ -5022,7 +5482,8 @@ Das ist Ihr Link für die Gruppe %@! Ok Ok - alert button + alert action +alert button Old database @@ -5083,6 +5544,16 @@ Dies erfordert die Aktivierung eines VPNs. Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden. No comment provided by engineer. + + Only sender and moderators see it + Nur Absender und Moderatoren sehen es + No comment provided by engineer. + + + Only you and moderators see it + Nur Sie und Moderatoren sehen es + No comment provided by engineer. + Only you can add message reactions. Nur Sie können Reaktionen auf Nachrichten geben. @@ -5136,7 +5607,7 @@ Dies erfordert die Aktivierung eines VPNs. Open Öffnen - No comment provided by engineer. + alert action Open Settings @@ -5168,6 +5639,11 @@ Dies erfordert die Aktivierung eines VPNs. Gruppe öffnen No comment provided by engineer. + + Open link? + Link öffnen? + alert title + Open migration to another device Migration auf ein anderes Gerät öffnen @@ -5218,6 +5694,11 @@ Dies erfordert die Aktivierung eines VPNs. Oder zum privaten Teilen No comment provided by engineer. + + Organize chats into lists + Chats in Listen verwalten + No comment provided by engineer. + Other Andere @@ -5409,6 +5890,26 @@ Fehler: %@ Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Bitte versuchen Sie, die Benachrichtigungen zu deaktivieren und wieder zu aktivieren. + token info + + + Please wait for group moderators to review your request to join the group. + Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können. + snd group event chat item + + + Please wait for token activation to complete. + Bitte warten Sie, bis die Token-Aktivierung abgeschlossen ist. + token info + + + Please wait for token to be registered. + Bitte warten Sie auf die Registrierung des Tokens. + token info + Polish interface Polnische Bedienoberfläche @@ -5459,16 +5960,31 @@ Fehler: %@ Schutz der Privatsphäre Ihrer Kunden. No comment provided by engineer. + + Privacy policy and conditions of use. + Datenschutz- und Nutzungsbedingungen. + No comment provided by engineer. + Privacy redefined Datenschutz neu definiert No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. + No comment provided by engineer. + Private filenames Neutrale Dateinamen No comment provided by engineer. + + Private media file names. + Medien mit anonymisierten Dateinamen. + No comment provided by engineer. + Private message routing Privates Nachrichten-Routing @@ -5544,6 +6060,11 @@ Fehler: %@ Reaktionen auf Nachrichten nicht erlauben. No comment provided by engineer. + + Prohibit reporting messages to moderators. + Melden von Nachrichten an Moderatoren nicht erlauben. + No comment provided by engineer. + Prohibit sending SimpleX links. Das Senden von SimpleX-Links nicht erlauben. @@ -5728,7 +6249,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Receiving file will be stopped. - Der Empfang der Datei wird beendet. + Das Herunterladen der Datei wird beendet. No comment provided by engineer. @@ -5801,11 +6322,26 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Reduzierter Batterieverbrauch No comment provided by engineer. + + Register + Registrieren + No comment provided by engineer. + + + Register notification token? + Benachrichtigungs-Token registrieren? + token info + + + Registered + Registriert + token status text + Reject Ablehnen reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5817,6 +6353,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Kontaktanfrage ablehnen No comment provided by engineer. + + Reject member? + Mitglied ablehnen? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden. @@ -5902,6 +6443,61 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Antwort chat item action + + Report + Melden + chat item action + + + Report content: only group moderators will see it. + Inhalt melden: Nur Gruppenmoderatoren werden es sehen. + report reason + + + Report member profile: only group moderators will see it. + Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen. + report reason + + + Report other: only group moderators will see it. + Anderes melden: Nur Gruppenmoderatoren werden es sehen. + report reason + + + Report reason? + Grund der Meldung? + No comment provided by engineer. + + + Report sent to moderators + Meldung wurde an die Moderatoren gesendet + alert title + + + Report spam: only group moderators will see it. + Spam melden: Nur Gruppenmoderatoren werden es sehen. + report reason + + + Report violation: only group moderators will see it. + Verstoß melden: Nur Gruppenmoderatoren werden es sehen. + report reason + + + Report: %@ + Meldung: %@ + report in notification + + + Reporting messages to moderators is prohibited. + Melden von Nachrichten an Moderatoren ist nicht erlaubt. + No comment provided by engineer. + + + Reports + Meldungen + No comment provided by engineer. + Required Erforderlich @@ -5992,10 +6588,15 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Nutzungsbedingungen einsehen No comment provided by engineer. - - Review later - Später einsehen - No comment provided by engineer. + + Review members + Überprüfung der Mitglieder + admission stage + + + Review members before admitting ("knocking"). + Überprüfung der Mitglieder vor der Aufnahme ("Anklopfen"). + admission stage description Revoke @@ -6034,7 +6635,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Safely receive files - Dateien sicher empfangen + Dateien sicher herunterladen No comment provided by engineer. @@ -6046,13 +6647,18 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Save Speichern alert button - chat item action +chat item action Save (and notify contacts) Speichern (und Kontakte benachrichtigen) alert button + + Save admission settings? + Speichern der Aufnahme-Einstellungen? + alert title + Save and notify contact Speichern und Kontakt benachrichtigen @@ -6078,6 +6684,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Gruppenprofil speichern No comment provided by engineer. + + Save list + Liste speichern + No comment provided by engineer. + Save passphrase and open chat Passwort speichern und Chat öffnen @@ -6318,6 +6929,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Benachrichtigungen senden No comment provided by engineer. + + Send private reports + Private Meldungen senden + No comment provided by engineer. + Send questions and ideas Senden Sie Fragen und Ideen @@ -6533,6 +7149,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Einen Tag festlegen No comment provided by engineer. + + Set chat name… + Chat-Name festlegen… + No comment provided by engineer. + Set contact name… Kontaktname festlegen… @@ -6553,6 +7174,16 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Anstelle der System-Authentifizierung festlegen. No comment provided by engineer. + + Set member admission + Aufnahme von Mitgliedern festlegen + No comment provided by engineer. + + + Set message expiration in chats. + Verfallsdatum von Nachrichten in Chats festlegen. + No comment provided by engineer. + Set passcode Zugangscode einstellen @@ -6597,7 +7228,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Share Teilen alert action - chat item action +chat item action Share 1-time link @@ -6659,6 +7290,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Mit Kontakten teilen No comment provided by engineer. + + Short link + Verkürzter Link + No comment provided by engineer. + Show QR code QR-Code anzeigen @@ -6759,6 +7395,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. SimpleX-Adresse oder Einmal-Link? No comment provided by engineer. + + SimpleX channel link + SimpleX-Kanal-Link + simplex link type + SimpleX contact address SimpleX-Kontaktadressen-Link @@ -6861,6 +7502,12 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Jemand notification title + + Spam + Spam + blocking reason +report reason + Square, circle, or anything in between. Quadratisch, kreisförmig oder irgendetwas dazwischen. @@ -6918,17 +7565,17 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Stop file - Datei beenden + Herunterladen beenden cancel file action Stop receiving file? - Den Empfang der Datei beenden? + Das Herunterladen der Datei beenden? No comment provided by engineer. Stop sending file? - Das Senden der Datei beenden? + Das Hochladen der Datei beenden? No comment provided by engineer. @@ -6946,6 +7593,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Chat wird beendet No comment provided by engineer. + + Storage + Ablage + No comment provided by engineer. + Strong Hart @@ -7006,6 +7658,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Timeout der TCP-Verbindung No comment provided by engineer. + + TCP port for messaging + TCP-Port für Nachrichtenübermittlung + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7074,13 +7731,18 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Temporary file error Temporärer Datei-Fehler - No comment provided by engineer. + file error alert title Test failed at step %@. Der Test ist beim Schritt %@ fehlgeschlagen. server test failure + + Test notifications + Benachrichtigungen testen + No comment provided by engineer. + Test server Teste Server @@ -7203,21 +7865,11 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Die alte Datenbank wurde während der Migration nicht entfernt. Sie kann gelöscht werden. No comment provided by engineer. - - The profile is only shared with your contacts. - Das Profil wird nur mit Ihren Kontakten geteilt. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Dieselben Nutzungsbedingungen gelten auch für den/die Betreiber: **%@**. - No comment provided by engineer. - The second preset operator in the app! Der zweite voreingestellte Netzwerk-Betreiber in der App! @@ -7275,17 +7927,22 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. + Es werden alle herunter- und hochgeladenen Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. + Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden! + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. @@ -7328,11 +7985,21 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Das ist Ihr eigener Einmal-Link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Dieser Link wurde schon mit einem anderen Mobiltelefon genutzt. Bitte erstellen sie einen neuen Link in der Desktop-App. No comment provided by engineer. + + This message was deleted or not received yet. + Diese Nachricht wurde gelöscht oder bisher noch nicht empfangen. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**. @@ -7445,6 +8112,11 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Inkognito beim Verbinden einschalten. No comment provided by engineer. + + Token status: %@. + Token-Status: %@. + token status + Toolbar opacity Deckkraft der Symbolleiste @@ -7610,13 +8282,18 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Unmute Stummschaltung aufheben - swipe action + notification label action Unread Ungelesen swipe action + + Unsupported connection link + Verbindungs-Link wird nicht unterstützt + No comment provided by engineer. + Up to 100 last messages are sent to new members. Bis zu 100 der letzten Nachrichten werden an neue Mitglieder gesendet. @@ -7642,6 +8319,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Einstellungen aktualisieren? No comment provided by engineer. + + Updated conditions + Aktualisierte Nutzungsbedingungen + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Die Aktualisierung der Einstellungen wird den Client wieder mit allen Servern verbinden. @@ -7702,6 +8384,16 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verwenden Sie SimpleX-Chat-Server? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + Solange kein Port konfiguriert ist, wird TCP-Port %@ genutzt. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + TCP-Port 443 nur für voreingestellte Server verwenden. + No comment provided by engineer. + Use chat Verwenden Sie Chat @@ -7767,6 +8459,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verwende Server No comment provided by engineer. + + Use short links (BETA) + Kurze Links verwenden (BETA) + No comment provided by engineer. + Use the app while in the call. Die App kann während eines Anrufs genutzt werden. @@ -7777,6 +8474,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Die App mit einer Hand bedienen. No comment provided by engineer. + + Use web port + Web-Port nutzen + No comment provided by engineer. + User selection Benutzer-Auswahl @@ -7844,12 +8546,12 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Video will be received when your contact completes uploading it. - Das Video wird empfangen, sobald Ihr Kontakt das Hochladen beendet hat. + Das Video wird heruntergeladen, sobald Ihr Kontakt das Hochladen beendet hat. No comment provided by engineer. Video will be received when your contact is online, please wait or check later! - Das Video wird empfangen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! + Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! No comment provided by engineer. @@ -8149,11 +8851,6 @@ Verbindungsanfrage wiederholen? Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Sie können die Betreiber in den Netzwerk- und Servereinstellungen konfigurieren. - No comment provided by engineer. - You can configure servers via settings. Sie können die Server über die Einstellungen konfigurieren. @@ -8244,6 +8941,11 @@ Verbindungsanfrage wiederholen? Den Einladungslink können Sie in den Details der Verbindung nochmals sehen. alert message + + You can view your reports in Chat with admins. + Sie können Ihre Meldungen im Chat mit den Administratoren sehen. + alert message + You can't send messages! Sie können keine Nachrichten versenden! @@ -8326,6 +9028,11 @@ Verbindungsanfrage wiederholen? Sie haben eine Gruppeneinladung gesendet No comment provided by engineer. + + You should receive notifications. + Sie sollten Benachrichtigungen erhalten. + token info + You will be connected to group when the group host's device is online, please wait or check later! Sie werden mit der Gruppe verbunden, sobald das Endgerät des Gruppen-Hosts online ist. Bitte warten oder schauen Sie später nochmal nach! @@ -8363,7 +9070,7 @@ Verbindungsanfrage wiederholen? You will stop receiving messages from this chat. Chat history will be preserved. - Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten. + Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten. No comment provided by engineer. @@ -8391,11 +9098,6 @@ Verbindungsanfrage wiederholen? Ihre ICE-Server No comment provided by engineer. - - Your SMP servers - Ihre SMP-Server - No comment provided by engineer. - Your SimpleX address Ihre SimpleX-Adresse @@ -8468,7 +9170,7 @@ Verbindungsanfrage wiederholen? Your privacy - Ihre Privatsphäre + Privatsphäre No comment provided by engineer. @@ -8481,6 +9183,11 @@ Verbindungsanfrage wiederholen? Ihr Profil **%@** wird geteilt. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt. SimpleX-Server können Ihr Profil nicht einsehen. @@ -8491,11 +9198,6 @@ Verbindungsanfrage wiederholen? Ihr Profil wurde geändert. Wenn Sie es speichern, wird das aktualisierte Profil an alle Ihre Kontakte gesendet. alert message - - Your profile, contacts and delivered messages are stored on your device. - Ihr Profil, Ihre Kontakte und zugestellten Nachrichten werden auf Ihrem Gerät gespeichert. - No comment provided by engineer. - Your random profile Ihr Zufallsprofil @@ -8546,6 +9248,11 @@ Verbindungsanfrage wiederholen? Danach die gewünschte Aktion auswählen: No comment provided by engineer. + + accepted %@ + %@ übernommen + rcv group event chat item + accepted call Anruf angenommen @@ -8553,9 +9260,14 @@ Verbindungsanfrage wiederholen? accepted invitation - Einladung akzeptiert + Einladung angenommen chat list item title + + accepted you + hat Sie übernommen + rcv group event chat item + admin Admin @@ -8576,6 +9288,11 @@ Verbindungsanfrage wiederholen? Verschlüsselung zustimmen… chat item text + + all + alle + member criteria value + all members Alle Mitglieder @@ -8591,6 +9308,11 @@ Verbindungsanfrage wiederholen? und %lld weitere Ereignisse No comment provided by engineer. + + archived report + Archivierte Meldung + No comment provided by engineer. + attempts Versuche @@ -8629,7 +9351,8 @@ Verbindungsanfrage wiederholen? blocked by admin wurde vom Administrator blockiert - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8656,6 +9379,11 @@ Verbindungsanfrage wiederholen? Anrufen… call status + + can't send messages + Es können keine Nachrichten gesendet werden + No comment provided by engineer. + cancelled %@ abgebrochen %@ @@ -8761,6 +9489,16 @@ Verbindungsanfrage wiederholen? Der Kontaktname wurde von %1$@ auf %2$@ geändert profile update event chat item + + contact deleted + Kontakt gelöscht + No comment provided by engineer. + + + contact disabled + Kontakt deaktiviert + No comment provided by engineer. + contact has e2e encryption Kontakt nutzt E2E-Verschlüsselung @@ -8771,6 +9509,11 @@ Verbindungsanfrage wiederholen? Kontakt nutzt keine E2E-Verschlüsselung No comment provided by engineer. + + contact not ready + Kontakt nicht bereit + No comment provided by engineer. + creator Ersteller @@ -8798,8 +9541,9 @@ Verbindungsanfrage wiederholen? default (%@) - Voreinstellung (%@) - pref value + Default (%@) + delete after time +pref value default (no) @@ -8926,21 +9670,11 @@ Verbindungsanfrage wiederholen? Fehler No comment provided by engineer. - - event happened - event happened - No comment provided by engineer. - expired Abgelaufen No comment provided by engineer. - - for better metadata privacy. - für einen besseren Metadatenschutz. - No comment provided by engineer. - forwarded weitergeleitet @@ -8951,6 +9685,11 @@ Verbindungsanfrage wiederholen? Gruppe gelöscht No comment provided by engineer. + + group is deleted + Gruppe wird gelöscht + No comment provided by engineer. + group profile updated Gruppenprofil aktualisiert @@ -9076,6 +9815,11 @@ Verbindungsanfrage wiederholen? ist der Gruppe beigetreten rcv group event chat item + + member has old version + Das Mitglied hat eine alte App-Version + No comment provided by engineer. + message Nachricht @@ -9106,20 +9850,20 @@ Verbindungsanfrage wiederholen? Von %@ moderiert marked deleted chat item preview text + + moderator + Moderator + member role + months Monate time unit - - mute - Stummschalten - No comment provided by engineer. - never nie - No comment provided by engineer. + delete after time new message @@ -9141,6 +9885,11 @@ Verbindungsanfrage wiederholen? Kein Text copied message info in history + + not synchronized + Nicht synchronisiert + No comment provided by engineer. + observer Beobachter @@ -9150,8 +9899,9 @@ Verbindungsanfrage wiederholen? off Aus enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9193,6 +9943,21 @@ Verbindungsanfrage wiederholen? Peer-to-Peer No comment provided by engineer. + + pending + ausstehend + No comment provided by engineer. + + + pending approval + ausstehende Genehmigung + No comment provided by engineer. + + + pending review + Ausstehende Überprüfung + No comment provided by engineer. + quantum resistant e2e encryption Quantum-resistente E2E-Verschlüsselung @@ -9208,6 +9973,11 @@ Verbindungsanfrage wiederholen? Bestätigung erhalten… No comment provided by engineer. + + rejected + abgelehnt + No comment provided by engineer. + rejected call Abgelehnter Anruf @@ -9228,6 +9998,11 @@ Verbindungsanfrage wiederholen? Die Kontaktadresse wurde entfernt profile update event chat item + + removed from group + Von der Gruppe entfernt + No comment provided by engineer. + removed profile picture Das Profil-Bild wurde entfernt @@ -9238,11 +10013,26 @@ Verbindungsanfrage wiederholen? hat Sie aus der Gruppe entfernt rcv group event chat item + + request to join rejected + Beitrittsanfrage abgelehnt + No comment provided by engineer. + requested to connect Zur Verbindung aufgefordert chat list item title + + review + Überprüfung + No comment provided by engineer. + + + reviewed by admins + Von Administratoren überprüft + No comment provided by engineer. + saved abgespeichert @@ -9342,11 +10132,6 @@ Zuletzt empfangene Nachricht: %2$@ unbekannter Gruppenmitglieds-Status No comment provided by engineer. - - unmute - Stummschaltung aufheben - No comment provided by engineer. - unprotected Ungeschützt @@ -9437,6 +10222,11 @@ Zuletzt empfangene Nachricht: %2$@ Profil No comment provided by engineer. + + you accepted this member + Sie haben dieses Mitglied übernommen + snd group event chat item + you are invited to group Sie sind zu der Gruppe eingeladen @@ -9511,7 +10301,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -9541,14 +10331,14 @@ Zuletzt empfangene Nachricht: %2$@ SimpleX needs access to Photo Library for saving captured and received media - SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder empfangene Bilder zu speichern + SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder heruntergeladene Bilder zu speichern Privacy - Photo Library Additions Usage Description
- +
@@ -9570,7 +10360,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -9578,6 +10368,11 @@ Zuletzt empfangene Nachricht: %2$@ %d neue Ereignisse notification body + + From %d chat(s) + Von %d Chat(s) + notification body + From: %@ Von: %@ @@ -9593,16 +10388,11 @@ Zuletzt empfangene Nachricht: %2$@ Neue Nachrichten notification - - New messages in %d chats - Neue Nachrichten in %d Chats - notification body -
- +
@@ -9617,14 +10407,14 @@ Zuletzt empfangene Nachricht: %2$@ Copyright © 2024 SimpleX Chat. All rights reserved. - Copyright © 2024 SimpleX Chat. Alle Rechte vorbehalten. + Copyright © 2025 SimpleX Chat. Alle Rechte vorbehalten. Copyright (human-readable)
- +
@@ -9659,17 +10449,17 @@ Zuletzt empfangene Nachricht: %2$@ Currently maximum supported file size is %@. - Die maximale erlaubte Dateigröße beträgt aktuell %@. + Die maximal erlaubte Dateigröße beträgt aktuell %@. No comment provided by engineer. Database downgrade required - Datenbank-Herabstufung erforderlich + Datenbank-Herunterstufung ist erforderlich No comment provided by engineer. Database encrypted! - Datenbank verschlüsselt! + Datenbank ist verschlüsselt! No comment provided by engineer. @@ -9684,7 +10474,7 @@ Zuletzt empfangene Nachricht: %2$@ Database passphrase is required to open chat. - Ein Datenbank-Passwort ist erforderlich, um den Chat zu öffnen. + Um den Chat zu öffnen, ist ein Datenbank-Passwort ist erforderlich. No comment provided by engineer. @@ -9709,7 +10499,7 @@ Zuletzt empfangene Nachricht: %2$@ File error - Dateifehler + Datei-Fehler No comment provided by engineer. @@ -9744,12 +10534,12 @@ Zuletzt empfangene Nachricht: %2$@ Open the app to downgrade the database. - Öffne die App, um die Datenbank herabzustufen. + Öffnen Sie die App, um die Datenbank herunterzustufen. No comment provided by engineer. Open the app to upgrade the database. - Öffne die App, um die Datenbank zu aktualisieren. + Öffnen Sie die App, um die Datenbank zu aktualisieren. No comment provided by engineer. @@ -9759,7 +10549,7 @@ Zuletzt empfangene Nachricht: %2$@ Please create a profile in the SimpleX app - Bitte erstelle ein Profil in der SimpleX-App + Bitte erstellen Sie ein Profil in der SimpleX-App No comment provided by engineer. @@ -9809,7 +10599,7 @@ Zuletzt empfangene Nachricht: %2$@ You can allow sharing in Privacy & Security / SimpleX Lock settings. - Du kannst das Teilen in den Einstellungen zu Datenschutz & Sicherheit - SimpleX-Sperre erlauben. + Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/contents.json b/apps/ios/SimpleX Localizations/de.xcloc/contents.json index 18b517d802..e8d71cf38c 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/de.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "de", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff index b601d1fa74..fc1846942c 100644 --- a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff +++ b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff @@ -3043,8 +3043,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer.
- - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index cebd6c90d1..5982f620b8 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (can be copied) @@ -202,6 +190,11 @@ %d sec time interval + + %d seconds(s) + %d seconds(s) + delete after time + %d skipped message(s) %d skipped message(s) @@ -272,11 +265,6 @@ %lld new interface languages No comment provided by engineer. - - %lld second(s) - %lld second(s) - No comment provided by engineer. - %lld seconds %lld seconds @@ -327,11 +315,6 @@ %u messages skipped. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (new) @@ -342,11 +325,6 @@ (this device v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Create 1-time link**: to create and share a new invitation link. @@ -412,11 +390,6 @@ \*bold* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -453,11 +426,6 @@ - editing history. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -471,7 +439,8 @@ 1 day 1 day - time interval + delete after time +time interval 1 hour @@ -486,12 +455,19 @@ 1 month 1 month - time interval + delete after time +time interval 1 week 1 week - time interval + delete after time +time interval + + + 1 year + 1 year + delete after time 1-time link @@ -518,11 +494,6 @@ 30 seconds No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -591,8 +562,18 @@ Accept Accept accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + Accept as member + alert action + + + Accept as observer + Accept as observer + alert action Accept conditions @@ -613,7 +594,12 @@ Accept incognito Accept incognito accept contact request via notification - swipe action +swipe action + + + Accept member + Accept member + alert title Accepted conditions @@ -630,6 +616,11 @@ Acknowledgement errors No comment provided by engineer. + + Active + Active + token status text + Active connections Active connections @@ -645,6 +636,11 @@ Add friends No comment provided by engineer. + + Add list + Add list + No comment provided by engineer. + Add profile Add profile @@ -670,6 +666,11 @@ Add to another device No comment provided by engineer. + + Add to list + Add to list + No comment provided by engineer. + Add welcome message Add welcome message @@ -745,6 +746,11 @@ Advanced settings No comment provided by engineer. + + All + All + No comment provided by engineer. + All app data is deleted. All app data is deleted. @@ -755,6 +761,11 @@ All chats and messages will be deleted - this cannot be undone! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. All data is erased when it is entered. @@ -795,6 +806,16 @@ All profiles profile dropdown + + All reports will be archived for you. + All reports will be archived for you. + No comment provided by engineer. + + + All servers + All servers + No comment provided by engineer. + All your contacts will remain connected. All your contacts will remain connected. @@ -870,6 +891,11 @@ Allow to irreversibly delete sent messages. (24 hours) No comment provided by engineer. + + Allow to report messsages to moderators. + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Allow to send SimpleX links. @@ -950,6 +976,11 @@ An empty chat profile with the provided name is created, and the app opens as usual. No comment provided by engineer. + + Another reason + Another reason + report reason + Answer call Answer call @@ -975,6 +1006,11 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + App group: + No comment provided by engineer. + App icon App icon @@ -1020,6 +1056,21 @@ Apply to No comment provided by engineer. + + Archive + Archive + No comment provided by engineer. + + + Archive %lld reports? + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + Archive all reports? + No comment provided by engineer. + Archive and upload Archive and upload @@ -1030,6 +1081,21 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + Archive report + No comment provided by engineer. + + + Archive report? + Archive report? + No comment provided by engineer. + + + Archive reports + Archive reports + swipe action + Archived contacts Archived contacts @@ -1140,6 +1206,11 @@ Better groups No comment provided by engineer. + + Better groups performance + Better groups performance + No comment provided by engineer. + Better message dates. Better message dates. @@ -1160,6 +1231,11 @@ Better notifications No comment provided by engineer. + + Better privacy and security + Better privacy and security + No comment provided by engineer. + Better security ✅ Better security ✅ @@ -1260,11 +1336,25 @@ Business chats No comment provided by engineer. + + Businesses + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Call already ended! @@ -1314,7 +1404,7 @@ Cancel Cancel alert action - alert button +alert button Cancel migration @@ -1351,6 +1441,11 @@ Change No comment provided by engineer. + + Change automatic message deletion? + Change automatic message deletion? + alert title + Change chat profiles Change chat profiles @@ -1400,7 +1495,7 @@ Change self-destruct passcode Change self-destruct passcode authentication reason - set passcode view +set passcode view Chat @@ -1502,11 +1597,26 @@ Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + Chat with admins + chat toolbar + + + Chat with member + Chat with member + No comment provided by engineer. + Chats Chats No comment provided by engineer. + + Chats with members + Chats with members + No comment provided by engineer. + Check messages every 20 min. Check messages every 20 min. @@ -1572,6 +1682,16 @@ Clear conversation? No comment provided by engineer. + + Clear group? + Clear group? + No comment provided by engineer. + + + Clear or delete group? + Clear or delete group? + No comment provided by engineer. + Clear private notes? Clear private notes? @@ -1592,6 +1712,11 @@ Color mode No comment provided by engineer. + + Community guidelines violation + Community guidelines violation + report reason + Compare file Compare file @@ -1627,16 +1752,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Conditions will be accepted for the operator(s): **%@**. @@ -1657,6 +1772,11 @@ Configure ICE servers No comment provided by engineer. + + Configure server operators + Configure server operators + No comment provided by engineer. + Confirm Confirm @@ -1707,6 +1827,11 @@ Confirm upload No comment provided by engineer. + + Confirmed + Confirmed + token status text + Connect Connect @@ -1826,6 +1951,11 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + Connection blocked + No comment provided by engineer. + Connection error Connection error @@ -1836,6 +1966,18 @@ This is your own one-time link! Connection error (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + Connection not ready. + No comment provided by engineer. + Connection notifications Connection notifications @@ -1846,6 +1988,11 @@ This is your own one-time link! Connection request sent! No comment provided by engineer. + + Connection requires encryption renegotiation. + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Connection security @@ -1926,6 +2073,11 @@ This is your own one-time link! Contacts can mark messages for deletion; you will be able to view them. No comment provided by engineer. + + Content violates conditions of use + Content violates conditions of use + blocking reason + Continue Continue @@ -2001,6 +2153,11 @@ This is your own one-time link! Create link No comment provided by engineer. + + Create list + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 @@ -2218,8 +2375,7 @@ This is your own one-time link! Delete Delete alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2261,6 +2417,11 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Delete chat profile @@ -2271,6 +2432,11 @@ This is your own one-time link! Delete chat profile? No comment provided by engineer. + + Delete chat with member? + Delete chat with member? + alert title + Delete chat? Delete chat? @@ -2351,6 +2517,11 @@ This is your own one-time link! Delete link? No comment provided by engineer. + + Delete list? + Delete list? + alert title + Delete member message? Delete member message? @@ -2364,7 +2535,7 @@ This is your own one-time link! Delete messages Delete messages - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2572,11 @@ This is your own one-time link! Delete queue server test step + + Delete report + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Delete up to 20 messages at once. @@ -2561,6 +2737,16 @@ This is your own one-time link! Disable SimpleX Lock authentication reason + + Disable automatic message deletion? + Disable automatic message deletion? + alert title + + + Disable delete messages + Disable delete messages + alert button + Disable for all Disable for all @@ -2651,6 +2837,11 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + Documents: + No comment provided by engineer. + Don't create address Don't create address @@ -2661,9 +2852,19 @@ This is your own one-time link! Don't enable No comment provided by engineer. + + Don't miss important messages. + Don't miss important messages. + No comment provided by engineer. + Don't show again Don't show again + alert action + + + Done + Done No comment provided by engineer. @@ -2675,7 +2876,7 @@ This is your own one-time link! Download Download alert button - chat item action +chat item action Download errors @@ -2752,9 +2953,9 @@ This is your own one-time link! Enable (keep overrides) No comment provided by engineer. - - Enable Flux - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2770,7 +2971,7 @@ This is your own one-time link! Enable automatic message deletion? Enable automatic message deletion? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3098,11 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Enter Passcode @@ -2972,6 +3178,11 @@ This is your own one-time link! Error accepting contact request No comment provided by engineer. + + Error accepting member + Error accepting member + alert title + Error adding member(s) Error adding member(s) @@ -3007,6 +3218,11 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Error connecting to forwarding server %@. Please try later. @@ -3027,6 +3243,11 @@ This is your own one-time link! Error creating group link No comment provided by engineer. + + Error creating list + Error creating list + alert title + Error creating member contact Error creating member contact @@ -3042,6 +3263,11 @@ This is your own one-time link! Error creating profile! No comment provided by engineer. + + Error creating report + Error creating report + No comment provided by engineer. + Error decrypting file Error decrypting file @@ -3052,6 +3278,11 @@ This is your own one-time link! Error deleting chat database No comment provided by engineer. + + Error deleting chat with member + Error deleting chat with member + alert title + Error deleting chat! Error deleting chat! @@ -3152,10 +3383,20 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + Error registering for notifications + alert title + Error removing member Error removing member - No comment provided by engineer. + alert title + + + Error reordering lists + Error reordering lists + alert title Error resetting statistics @@ -3167,6 +3408,11 @@ This is your own one-time link! Error saving ICE servers No comment provided by engineer. + + Error saving chat list + Error saving chat list + alert title + Error saving group profile Error saving group profile @@ -3247,6 +3493,11 @@ This is your own one-time link! Error synchronizing connection No comment provided by engineer. + + Error testing server connection + Error testing server connection + No comment provided by engineer. + Error updating group link Error updating group link @@ -3290,7 +3541,9 @@ This is your own one-time link! Error: %@ Error: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3327,6 +3580,11 @@ This is your own one-time link! Expand chat item action + + Expired + Expired + token status text + Export database Export database @@ -3367,20 +3625,35 @@ This is your own one-time link! Fast and no wait until the sender is online! No comment provided by engineer. + + Faster deletion of groups. + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + Faster sending messages. + No comment provided by engineer. + Favorite Favorite swipe action + + Favorites + Favorites + No comment provided by engineer. + File error File error - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3662,13 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +%@. + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. File not found - most likely file was deleted or cancelled. @@ -3514,6 +3794,11 @@ This is your own one-time link! Fix not supported by group member No comment provided by engineer. + + For all moderators + For all moderators + No comment provided by engineer. + For chat profile %@: For chat profile %@: @@ -3529,6 +3814,11 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + For me + No comment provided by engineer. + For private routing For private routing @@ -3653,6 +3943,11 @@ Error: %2$@ GIFs and stickers No comment provided by engineer. + + Get notified when mentioned. + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Good afternoon! @@ -3758,11 +4053,21 @@ Error: %2$@ Group will be deleted for you - this cannot be undone! No comment provided by engineer. + + Groups + Groups + No comment provided by engineer. + Help Help No comment provided by engineer. + + Help admins moderating their groups. + Help admins moderating their groups. + No comment provided by engineer. + Hidden Hidden @@ -3823,6 +4128,11 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + How it works + alert button + How to How to @@ -3965,6 +4275,16 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + Inappropriate content + report reason + + + Inappropriate profile + Inappropriate profile + report reason + Incognito Incognito @@ -4057,6 +4377,31 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + Invalid + token status text + + + Invalid (bad token) + Invalid (bad token) + token status text + + + Invalid (expired) + Invalid (expired) + token status text + + + Invalid (unregistered) + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + Invalid (wrong topic) + token status text + Invalid QR code Invalid QR code @@ -4330,6 +4675,21 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + List + swipe action + + + List name and emoji should be different for all lists. + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + List name... + No comment provided by engineer. + Live message! Live message! @@ -4415,11 +4775,21 @@ This is your link for group %@! Member No comment provided by engineer. + + Member admission + Member admission + No comment provided by engineer. + Member inactive Member inactive item status text + + Member reports + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Member role will be changed to "%@". All chat members will be notified. @@ -4445,6 +4815,11 @@ This is your link for group %@! Member will be removed from group - this cannot be undone! No comment provided by engineer. + + Member will join the group, accept member? + Member will join the group, accept member? + alert message + Members can add message reactions. Members can add message reactions. @@ -4455,6 +4830,11 @@ This is your link for group %@! Members can irreversibly delete sent messages. (24 hours) No comment provided by engineer. + + Members can report messsages to moderators. + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Members can send SimpleX links. @@ -4480,6 +4860,11 @@ This is your link for group %@! Members can send voice messages. No comment provided by engineer. + + Mention members 👋 + Mention members 👋 + No comment provided by engineer. + Menus Menus @@ -4590,6 +4975,11 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + Messages in this chat will never be deleted. + alert message + Messages received Messages received @@ -4690,6 +5080,11 @@ This is your link for group %@! Moderated at: %@ copied message info + + More + More + swipe action + More improvements are coming soon! More improvements are coming soon! @@ -4718,7 +5113,12 @@ This is your link for group %@! Mute Mute - swipe action + notification label action + + + Mute all + Mute all + notification label action Muted when inactive! @@ -4770,6 +5170,11 @@ This is your link for group %@! Network status No comment provided by engineer. + + New + New + token status text + New Passcode New Passcode @@ -4835,6 +5240,11 @@ This is your link for group %@! New member role No comment provided by engineer. + + New member wants to join the group. + New member wants to join the group. + rcv group event chat item + New message New message @@ -4860,6 +5270,26 @@ This is your link for group %@! No app password Authentication unavailable + + No chats + No chats + No comment provided by engineer. + + + No chats found + No chats found + No comment provided by engineer. + + + No chats in list %@ + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No chats with members + No comment provided by engineer. + No contacts selected No contacts selected @@ -4910,6 +5340,11 @@ This is your link for group %@! No media & file servers. servers error + + No message + No message + No comment provided by engineer. + No message servers. No message servers. @@ -4965,6 +5400,16 @@ This is your link for group %@! No servers to send files. servers error + + No token! + No token! + alert title + + + No unread chats + No unread chats + No comment provided by engineer. + No user identifiers. No user identifiers. @@ -4975,6 +5420,11 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + Notes + No comment provided by engineer. + Nothing selected Nothing selected @@ -4995,11 +5445,21 @@ This is your link for group %@! Notifications are disabled! No comment provided by engineer. + + Notifications error + Notifications error + alert title + Notifications privacy Notifications privacy No comment provided by engineer. + + Notifications status + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5022,7 +5482,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -5083,6 +5544,16 @@ Requires compatible VPN. Only group owners can enable voice messages. No comment provided by engineer. + + Only sender and moderators see it + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Only you can add message reactions. @@ -5136,7 +5607,7 @@ Requires compatible VPN. Open Open - No comment provided by engineer. + alert action Open Settings @@ -5168,6 +5639,11 @@ Requires compatible VPN. Open group No comment provided by engineer. + + Open link? + Open link? + alert title + Open migration to another device Open migration to another device @@ -5218,6 +5694,11 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + Organize chats into lists + No comment provided by engineer. + Other Other @@ -5409,6 +5890,26 @@ Error: %@ Please store passphrase securely, you will NOT be able to change it if you lose it. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + Please wait for token to be registered. + token info + Polish interface Polish interface @@ -5459,16 +5960,31 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Privacy redefined No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Private filenames No comment provided by engineer. + + Private media file names. + Private media file names. + No comment provided by engineer. + Private message routing Private message routing @@ -5544,6 +6060,11 @@ Error: %@ Prohibit messages reactions. No comment provided by engineer. + + Prohibit reporting messages to moderators. + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Prohibit sending SimpleX links. @@ -5801,11 +6322,26 @@ Enable in *Network & servers* settings. Reduced battery usage No comment provided by engineer. + + Register + Register + No comment provided by engineer. + + + Register notification token? + Register notification token? + token info + + + Registered + Registered + token status text + Reject Reject reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5817,6 +6353,11 @@ Enable in *Network & servers* settings. Reject contact request No comment provided by engineer. + + Reject member? + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relay server is only used if necessary. Another party can observe your IP address. @@ -5902,6 +6443,61 @@ Enable in *Network & servers* settings. Reply chat item action + + Report + Report + chat item action + + + Report content: only group moderators will see it. + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + Report other: only group moderators will see it. + report reason + + + Report reason? + Report reason? + No comment provided by engineer. + + + Report sent to moderators + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + Report violation: only group moderators will see it. + report reason + + + Report: %@ + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + Reports + No comment provided by engineer. + Required Required @@ -5992,10 +6588,15 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - Review later - No comment provided by engineer. + + Review members + Review members + admission stage + + + Review members before admitting ("knocking"). + Review members before admitting ("knocking"). + admission stage description Revoke @@ -6046,13 +6647,18 @@ Enable in *Network & servers* settings. Save Save alert button - chat item action +chat item action Save (and notify contacts) Save (and notify contacts) alert button + + Save admission settings? + Save admission settings? + alert title + Save and notify contact Save and notify contact @@ -6078,6 +6684,11 @@ Enable in *Network & servers* settings. Save group profile No comment provided by engineer. + + Save list + Save list + No comment provided by engineer. + Save passphrase and open chat Save passphrase and open chat @@ -6318,6 +6929,11 @@ Enable in *Network & servers* settings. Send notifications No comment provided by engineer. + + Send private reports + Send private reports + No comment provided by engineer. + Send questions and ideas Send questions and ideas @@ -6533,6 +7149,11 @@ Enable in *Network & servers* settings. Set 1 day No comment provided by engineer. + + Set chat name… + Set chat name… + No comment provided by engineer. + Set contact name… Set contact name… @@ -6553,6 +7174,16 @@ Enable in *Network & servers* settings. Set it instead of system authentication. No comment provided by engineer. + + Set member admission + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + Set message expiration in chats. + No comment provided by engineer. + Set passcode Set passcode @@ -6597,7 +7228,7 @@ Enable in *Network & servers* settings. Share Share alert action - chat item action +chat item action Share 1-time link @@ -6659,6 +7290,11 @@ Enable in *Network & servers* settings. Share with contacts No comment provided by engineer. + + Short link + Short link + No comment provided by engineer. + Show QR code Show QR code @@ -6759,6 +7395,11 @@ Enable in *Network & servers* settings. SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + SimpleX channel link + simplex link type + SimpleX contact address SimpleX contact address @@ -6861,6 +7502,12 @@ Enable in *Network & servers* settings. Somebody notification title + + Spam + Spam + blocking reason +report reason + Square, circle, or anything in between. Square, circle, or anything in between. @@ -6946,6 +7593,11 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + Storage + No comment provided by engineer. + Strong Strong @@ -7006,6 +7658,11 @@ Enable in *Network & servers* settings. TCP connection timeout No comment provided by engineer. + + TCP port for messaging + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7074,13 +7731,18 @@ Enable in *Network & servers* settings. Temporary file error Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. Test failed at step %@. server test failure + + Test notifications + Test notifications + No comment provided by engineer. + Test server Test server @@ -7203,21 +7865,11 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. - The profile is only shared with your contacts. - No comment provided by engineer. - The same conditions will apply to operator **%@**. The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! The second preset operator in the app! @@ -7283,6 +7935,11 @@ It can happen because of some bug or when the connection is compromised.This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. @@ -7328,11 +7985,21 @@ It can happen because of some bug or when the connection is compromised.This is your own one-time link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. This setting applies to messages in your current chat profile **%@**. @@ -7445,6 +8112,11 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + Token status: %@. + token status + Toolbar opacity Toolbar opacity @@ -7610,13 +8282,18 @@ To connect, please ask your contact to create another connection link and check Unmute Unmute - swipe action + notification label action Unread Unread swipe action + + Unsupported connection link + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Up to 100 last messages are sent to new members. @@ -7642,6 +8319,11 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Updating settings will re-connect the client to all servers. @@ -7702,6 +8384,16 @@ To connect, please ask your contact to create another connection link and check Use SimpleX Chat servers? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Use chat @@ -7767,6 +8459,11 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Use the app while in the call. @@ -7777,6 +8474,11 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + Use web port + No comment provided by engineer. + User selection User selection @@ -8149,11 +8851,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. You can configure servers via settings. @@ -8244,6 +8941,11 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + You can view your reports in Chat with admins. + alert message + You can't send messages! You can't send messages! @@ -8326,6 +9028,11 @@ Repeat connection request? You sent group invitation No comment provided by engineer. + + You should receive notifications. + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! You will be connected to group when the group host's device is online, please wait or check later! @@ -8391,11 +9098,6 @@ Repeat connection request? Your ICE servers No comment provided by engineer. - - Your SMP servers - Your SMP servers - No comment provided by engineer. - Your SimpleX address Your SimpleX address @@ -8481,6 +9183,11 @@ Repeat connection request? Your profile **%@** will be shared. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Your profile is stored on your device and only shared with your contacts. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -8491,11 +9198,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Your profile, contacts and delivered messages are stored on your device. - No comment provided by engineer. - Your random profile Your random profile @@ -8546,6 +9248,11 @@ Repeat connection request? above, then choose: No comment provided by engineer. + + accepted %@ + accepted %@ + rcv group event chat item + accepted call accepted call @@ -8556,6 +9263,11 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + accepted you + rcv group event chat item + admin admin @@ -8576,6 +9288,11 @@ Repeat connection request? agreeing encryption… chat item text + + all + all + member criteria value + all members all members @@ -8591,6 +9308,11 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + archived report + No comment provided by engineer. + attempts attempts @@ -8629,7 +9351,8 @@ Repeat connection request? blocked by admin blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8656,6 +9379,11 @@ Repeat connection request? calling… call status + + can't send messages + can't send messages + No comment provided by engineer. + cancelled %@ cancelled %@ @@ -8761,6 +9489,16 @@ Repeat connection request? contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + contact deleted + No comment provided by engineer. + + + contact disabled + contact disabled + No comment provided by engineer. + contact has e2e encryption contact has e2e encryption @@ -8771,6 +9509,11 @@ Repeat connection request? contact has no e2e encryption No comment provided by engineer. + + contact not ready + contact not ready + No comment provided by engineer. + creator creator @@ -8799,7 +9542,8 @@ Repeat connection request? default (%@) default (%@) - pref value + delete after time +pref value default (no) @@ -8926,21 +9670,11 @@ Repeat connection request? error No comment provided by engineer. - - event happened - event happened - No comment provided by engineer. - expired expired No comment provided by engineer. - - for better metadata privacy. - for better metadata privacy. - No comment provided by engineer. - forwarded forwarded @@ -8951,6 +9685,11 @@ Repeat connection request? group deleted No comment provided by engineer. + + group is deleted + group is deleted + No comment provided by engineer. + group profile updated group profile updated @@ -9076,6 +9815,11 @@ Repeat connection request? connected rcv group event chat item + + member has old version + member has old version + No comment provided by engineer. + message message @@ -9106,20 +9850,20 @@ Repeat connection request? moderated by %@ marked deleted chat item preview text + + moderator + moderator + member role + months months time unit - - mute - mute - No comment provided by engineer. - never never - No comment provided by engineer. + delete after time new message @@ -9141,6 +9885,11 @@ Repeat connection request? no text copied message info in history + + not synchronized + not synchronized + No comment provided by engineer. + observer observer @@ -9150,8 +9899,9 @@ Repeat connection request? off off enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9193,6 +9943,21 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + pending + No comment provided by engineer. + + + pending approval + pending approval + No comment provided by engineer. + + + pending review + pending review + No comment provided by engineer. + quantum resistant e2e encryption quantum resistant e2e encryption @@ -9208,6 +9973,11 @@ Repeat connection request? received confirmation… No comment provided by engineer. + + rejected + rejected + No comment provided by engineer. + rejected call rejected call @@ -9228,6 +9998,11 @@ Repeat connection request? removed contact address profile update event chat item + + removed from group + removed from group + No comment provided by engineer. + removed profile picture removed profile picture @@ -9238,11 +10013,26 @@ Repeat connection request? removed you rcv group event chat item + + request to join rejected + request to join rejected + No comment provided by engineer. + requested to connect requested to connect chat list item title + + review + review + No comment provided by engineer. + + + reviewed by admins + reviewed by admins + No comment provided by engineer. + saved saved @@ -9342,11 +10132,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - unmute - No comment provided by engineer. - unprotected unprotected @@ -9437,6 +10222,11 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + you accepted this member + snd group event chat item + you are invited to group you are invited to group @@ -9511,7 +10301,7 @@ last received msg: %2$@
- +
@@ -9548,7 +10338,7 @@ last received msg: %2$@
- +
@@ -9570,7 +10360,7 @@ last received msg: %2$@
- +
@@ -9578,6 +10368,11 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + From %d chat(s) + notification body + From: %@ From: %@ @@ -9593,16 +10388,11 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - New messages in %d chats - notification body -
- +
@@ -9624,7 +10414,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/contents.json b/apps/ios/SimpleX Localizations/en.xcloc/contents.json index 2f39a1f1ee..ec2accf27e 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/en.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "en", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 08522cc617..3c3ae9ff46 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (puede copiarse) @@ -202,6 +190,11 @@ %d segundo(s) time interval + + %d seconds(s) + %d segundos + delete after time + %d skipped message(s) %d mensaje(s) omitido(s) @@ -272,11 +265,6 @@ %lld idiomas de interfaz nuevos No comment provided by engineer. - - %lld second(s) - %lld segundo(s) - No comment provided by engineer. - %lld seconds %lld segundos @@ -327,11 +315,6 @@ %u mensaje(s) omitido(s). No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nuevo) @@ -342,11 +325,6 @@ (este dispositivo v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Añadir contacto**: crea un enlace de invitación nuevo. @@ -374,7 +352,7 @@ **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Atención**: NO podrás recuperar o cambiar la contraseña si la pierdes. + **Atención**: Si la pierdes NO podrás recuperar o cambiar la contraseña. No comment provided by engineer. @@ -412,11 +390,6 @@ \*bold* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -453,11 +426,6 @@ - historial de edición. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 seg @@ -471,7 +439,8 @@ 1 day un dia - time interval + delete after time +time interval 1 hour @@ -486,12 +455,19 @@ 1 month un mes - time interval + delete after time +time interval 1 week una semana - time interval + delete after time +time interval + + + 1 year + 1 año + delete after time 1-time link @@ -518,11 +494,6 @@ 30 segundos No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -554,7 +525,7 @@ A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**. -**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar. +**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar. No comment provided by engineer. @@ -591,8 +562,16 @@ Accept Aceptar accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions @@ -613,7 +592,11 @@ Accept incognito Aceptar incógnito accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions @@ -630,6 +613,11 @@ Errores de confirmación No comment provided by engineer. + + Active + Activo + token status text + Active connections Conexiones activas @@ -645,6 +633,11 @@ Añadir amigos No comment provided by engineer. + + Add list + Añadir lista + No comment provided by engineer. + Add profile Añadir perfil @@ -670,6 +663,11 @@ Añadir a otro dispositivo No comment provided by engineer. + + Add to list + Añadir a la lista + No comment provided by engineer. + Add welcome message Añadir mensaje de bienvenida @@ -677,7 +675,7 @@ Add your team members to the conversations. - Añade a los miembros de tu equipo a las conversaciones. + Añade a miembros de tu equipo a las conversaciones. No comment provided by engineer. @@ -722,7 +720,7 @@ Address settings - Configuración de dirección + Configurar dirección No comment provided by engineer. @@ -745,6 +743,11 @@ Configuración avanzada No comment provided by engineer. + + All + Todo + No comment provided by engineer. + All app data is deleted. Todos los datos de la aplicación se eliminarán. @@ -752,9 +755,14 @@ All chats and messages will be deleted - this cannot be undone! - Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse! + Se eliminarán todos los chats y mensajes. ¡No puede deshacerse! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Todos los chats se quitarán de la lista %@ y esta será eliminada. + alert message + All data is erased when it is entered. Al introducirlo todos los datos son eliminados. @@ -777,12 +785,12 @@ All messages will be deleted - this cannot be undone! - Todos los mensajes serán borrados. ¡No podrá deshacerse! + Todos los mensajes serán eliminados. ¡No puede deshacerse! No comment provided by engineer. All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. - Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse! + Se eliminarán todos los mensajes SOLO para tí. ¡No puede deshacerse! No comment provided by engineer. @@ -795,6 +803,16 @@ Todos los perfiles profile dropdown + + All reports will be archived for you. + Todos los informes serán archivados para ti. + No comment provided by engineer. + + + All servers + Todos los servidores + No comment provided by engineer. + All your contacts will remain connected. Todos tus contactos permanecerán conectados. @@ -870,6 +888,11 @@ Se permite la eliminación irreversible de mensajes. (24 horas) No comment provided by engineer. + + Allow to report messsages to moderators. + Permitir informar de mensajes a los moderadores. + No comment provided by engineer. + Allow to send SimpleX links. Se permite enviar enlaces SimpleX. @@ -950,6 +973,11 @@ Se creará un perfil vacío con el nombre proporcionado, y la aplicación se abrirá como de costumbre. No comment provided by engineer. + + Another reason + Otro motivo + report reason + Answer call Responder llamada @@ -975,9 +1003,14 @@ Cifrado de los nuevos archivos locales (excepto vídeos). No comment provided by engineer. + + App group: + Grupo app: + No comment provided by engineer. + App icon - Icono aplicación + Icono de la aplicación No comment provided by engineer. @@ -992,7 +1025,7 @@ App session - Sesión de aplicación + por sesión No comment provided by engineer. @@ -1020,6 +1053,21 @@ Aplicar a No comment provided by engineer. + + Archive + Archivar + No comment provided by engineer. + + + Archive %lld reports? + ¿Archivar %lld informes? + No comment provided by engineer. + + + Archive all reports? + ¿Archivar todos los informes? + No comment provided by engineer. + Archive and upload Archivar y subir @@ -1030,6 +1078,21 @@ Archiva contactos para charlar más tarde. No comment provided by engineer. + + Archive report + Archivar informe + No comment provided by engineer. + + + Archive report? + ¿Archivar informe? + No comment provided by engineer. + + + Archive reports + Archivar informes + swipe action + Archived contacts Contactos archivados @@ -1140,6 +1203,11 @@ Grupos mejorados No comment provided by engineer. + + Better groups performance + Rendimiento de grupos mejorado + No comment provided by engineer. + Better message dates. Sistema de fechas mejorado. @@ -1160,6 +1228,11 @@ Notificaciones mejoradas No comment provided by engineer. + + Better privacy and security + Privacidad y seguridad mejoradas + No comment provided by engineer. + Better security ✅ Seguridad mejorada ✅ @@ -1260,11 +1333,25 @@ Chats empresariales No comment provided by engineer. + + Businesses + Empresas + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Al usar SimpleX Chat, aceptas: +- enviar únicamente contenido legal en los grupos públicos. +- respetar a los demás usuarios – spam prohibido. + No comment provided by engineer. + Call already ended! ¡La llamada ha terminado! @@ -1314,7 +1401,7 @@ Cancel Cancelar alert action - alert button +alert button Cancel migration @@ -1351,6 +1438,11 @@ Cambiar No comment provided by engineer. + + Change automatic message deletion? + ¿Modificar la eliminación automática de mensajes? + alert title + Change chat profiles Cambiar perfil de usuario @@ -1400,7 +1492,7 @@ Change self-destruct passcode Cambiar código autodestrucción authentication reason - set passcode view +set passcode view Chat @@ -1494,12 +1586,20 @@ Chat will be deleted for all members - this cannot be undone! - El chat será eliminado para todos los miembros. ¡No podrá deshacerse! + El chat será eliminado para todos los miembros. ¡No puede deshacerse! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - El chat será eliminado para tí. ¡No podrá deshacerse! + El chat será eliminado para tí. ¡No puede deshacerse! + No comment provided by engineer. + + + Chat with admins + chat toolbar + + + Chat with member No comment provided by engineer. @@ -1507,6 +1607,10 @@ Chats No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Comprobar mensajes cada 20 min. @@ -1572,9 +1676,19 @@ ¿Vaciar conversación? No comment provided by engineer. + + Clear group? + ¿Vaciar grupo? + No comment provided by engineer. + + + Clear or delete group? + ¿Vaciar o eliminar grupo? + No comment provided by engineer. + Clear private notes? - ¿Borrar notas privadas? + ¿Eliminar notas privadas? No comment provided by engineer. @@ -1592,6 +1706,11 @@ Modo de color No comment provided by engineer. + + Community guidelines violation + Violación de las normas de la comunidad + report reason + Compare file Comparar archivo @@ -1627,16 +1746,6 @@ Condiciones de uso No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Las condiciones de los operadores habilitados serán aceptadas después de 30 días. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Las condiciones serán aceptadas para el/los operador(es): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Las condiciones serán aceptadas para el/los operador(es): **%@**. @@ -1657,6 +1766,11 @@ Configure servidores ICE No comment provided by engineer. + + Configure server operators + Configurar operadores de servidores + No comment provided by engineer. + Confirm Confirmar @@ -1707,6 +1821,11 @@ Confirmar subida No comment provided by engineer. + + Confirmed + Confirmado + token status text + Connect Conectar @@ -1826,6 +1945,11 @@ This is your own one-time link! Estado de tu conexión y servidores. No comment provided by engineer. + + Connection blocked + Conexión bloqueada + No comment provided by engineer. + Connection error Error conexión @@ -1836,6 +1960,18 @@ This is your own one-time link! Error de conexión (Autenticación) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + Conexión bloqueada por el operador del servidor: +%@ + No comment provided by engineer. + + + Connection not ready. + Conexión no establecida. + No comment provided by engineer. + Connection notifications Notificaciones de conexión @@ -1846,6 +1982,11 @@ This is your own one-time link! ¡Solicitud de conexión enviada! No comment provided by engineer. + + Connection requires encryption renegotiation. + La conexión requiere renegociar el cifrado. + No comment provided by engineer. + Connection security Seguridad de conexión @@ -1913,7 +2054,7 @@ This is your own one-time link! Contact will be deleted - this cannot be undone! - El contacto será eliminado. ¡No podrá deshacerse! + El contacto será eliminado. ¡No puede deshacerse! No comment provided by engineer. @@ -1926,6 +2067,11 @@ This is your own one-time link! Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. No comment provided by engineer. + + Content violates conditions of use + El contenido viola las condiciones de uso + blocking reason + Continue Continuar @@ -2001,6 +2147,11 @@ This is your own one-time link! Crear enlace No comment provided by engineer. + + Create list + Crear lista + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻 @@ -2218,8 +2369,7 @@ This is your own one-time link! Delete Eliminar alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2261,6 +2411,11 @@ This is your own one-time link! Eliminar chat No comment provided by engineer. + + Delete chat messages from your device. + Elimina los mensajes del dispositivo. + No comment provided by engineer. + Delete chat profile Eliminar perfil @@ -2271,6 +2426,10 @@ This is your own one-time link! ¿Eliminar perfil? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? ¿Eliminar chat? @@ -2351,6 +2510,11 @@ This is your own one-time link! ¿Eliminar enlace? No comment provided by engineer. + + Delete list? + ¿Eliminar lista? + alert title + Delete member message? ¿Eliminar el mensaje de miembro? @@ -2363,8 +2527,8 @@ This is your own one-time link! Delete messages - Eliminar mensaje - No comment provided by engineer. + Activar + alert button Delete messages after @@ -2383,7 +2547,7 @@ This is your own one-time link! Delete or moderate up to 200 messages. - Borra o modera hasta 200 mensajes a la vez. + Elimina o modera hasta 200 mensajes a la vez. No comment provided by engineer. @@ -2401,6 +2565,11 @@ This is your own one-time link! Eliminar cola server test step + + Delete report + Eliminar informe + No comment provided by engineer. + Delete up to 20 messages at once. Elimina hasta 20 mensajes a la vez. @@ -2561,6 +2730,16 @@ This is your own one-time link! Desactivar Bloqueo SimpleX authentication reason + + Disable automatic message deletion? + ¿Desactivar la eliminación automática de mensajes? + alert title + + + Disable delete messages + Desactivar + alert button + Disable for all Desactivar para todos @@ -2651,6 +2830,11 @@ This is your own one-time link! No uses credenciales con proxy. No comment provided by engineer. + + Documents: + Documentos: + No comment provided by engineer. + Don't create address No crear dirección SimpleX @@ -2661,9 +2845,19 @@ This is your own one-time link! No activar No comment provided by engineer. + + Don't miss important messages. + No pierdas los mensajes importantes. + No comment provided by engineer. + Don't show again No volver a mostrar + alert action + + + Done + Hecho No comment provided by engineer. @@ -2675,7 +2869,7 @@ This is your own one-time link! Download Descargar alert button - chat item action +chat item action Download errors @@ -2752,9 +2946,9 @@ This is your own one-time link! Activar (conservar anulaciones) No comment provided by engineer. - - Enable Flux - Habilita Flux + + Enable Flux in Network & servers settings for better metadata privacy. + Habilitar Flux en la configuración de Red y servidores para mejorar la privacidad de los metadatos. No comment provided by engineer. @@ -2770,7 +2964,7 @@ This is your own one-time link! Enable automatic message deletion? ¿Activar eliminación automática de mensajes? - No comment provided by engineer. + alert title Enable camera access @@ -2839,7 +3033,7 @@ This is your own one-time link! Encrypt local files - Cifra archivos locales + Cifrar archivos locales No comment provided by engineer. @@ -2897,6 +3091,11 @@ This is your own one-time link! Renegociación de cifrado fallida. No comment provided by engineer. + + Encryption renegotiation in progress. + Renegociación de cifrado en curso. + No comment provided by engineer. + Enter Passcode Introduce Código @@ -2929,7 +3128,7 @@ This is your own one-time link! Enter server manually - Introduce el servidor manualmente + Añadir manualmente No comment provided by engineer. @@ -2972,6 +3171,10 @@ This is your own one-time link! Error al aceptar solicitud del contacto No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Error al añadir miembro(s) @@ -3007,6 +3210,11 @@ This is your own one-time link! ¡Error al cambiar a incógnito! No comment provided by engineer. + + Error checking token status + Error al verificar el estado del token + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde. @@ -3027,6 +3235,11 @@ This is your own one-time link! Error al crear enlace de grupo No comment provided by engineer. + + Error creating list + Error al crear lista + alert title + Error creating member contact Error al establecer contacto con el miembro @@ -3042,6 +3255,11 @@ This is your own one-time link! ¡Error al crear perfil! No comment provided by engineer. + + Error creating report + Error al crear informe + No comment provided by engineer. + Error decrypting file Error al descifrar el archivo @@ -3052,6 +3270,10 @@ This is your own one-time link! Error al eliminar base de datos No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! ¡Error al eliminar chat! @@ -3152,10 +3374,20 @@ This is your own one-time link! Error al reconectar con los servidores No comment provided by engineer. + + Error registering for notifications + Error al registrarse para notificaciones + alert title + Error removing member - Error al eliminar miembro - No comment provided by engineer. + Error al expulsar miembro + alert title + + + Error reordering lists + Error al reorganizar listas + alert title Error resetting statistics @@ -3167,6 +3399,11 @@ This is your own one-time link! Error al guardar servidores ICE No comment provided by engineer. + + Error saving chat list + Error al guardar listas + alert title + Error saving group profile Error al guardar perfil de grupo @@ -3247,6 +3484,11 @@ This is your own one-time link! Error al sincronizar conexión No comment provided by engineer. + + Error testing server connection + Error al testar la conexión al servidor + No comment provided by engineer. + Error updating group link Error al actualizar enlace de grupo @@ -3290,7 +3532,9 @@ This is your own one-time link! Error: %@ Error: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3327,6 +3571,11 @@ This is your own one-time link! Expandir chat item action + + Expired + Expirado + token status text + Export database Exportar base de datos @@ -3367,20 +3616,35 @@ This is your own one-time link! ¡Rápido y sin necesidad de esperar a que el remitente esté en línea! No comment provided by engineer. + + Faster deletion of groups. + Eliminación más rápida de grupos. + No comment provided by engineer. + Faster joining and more reliable messages. Mensajería más segura y conexión más rápida. No comment provided by engineer. + + Faster sending messages. + Envío más rápido de mensajes. + No comment provided by engineer. + Favorite Favoritos swipe action + + Favorites + Favoritos + No comment provided by engineer. + File error Error de archivo - No comment provided by engineer. + file error alert title File errors: @@ -3389,9 +3653,16 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +%@. + Archivo bloqueado por el operador del servidor +%@. + file error text + File not found - most likely file was deleted or cancelled. - Archivo no encontrado, probablemente haya sido borrado o cancelado. + Archivo no encontrado, probablemente haya sido eliminado o cancelado. file error text @@ -3514,6 +3785,11 @@ This is your own one-time link! Corrección no compatible con miembro del grupo No comment provided by engineer. + + For all moderators + Para todos los moderadores + No comment provided by engineer. + For chat profile %@: Para el perfil de chat %@: @@ -3529,9 +3805,14 @@ This is your own one-time link! Si por ejemplo tu contacto recibe los mensajes a través de un servidor de SimpleX Chat, tu aplicación los entregará a través de un servidor de Flux. No comment provided by engineer. + + For me + para mí + No comment provided by engineer. + For private routing - Para el enrutamiento privado + Para enrutamiento privado No comment provided by engineer. @@ -3653,6 +3934,11 @@ Error: %2$@ GIFs y stickers No comment provided by engineer. + + Get notified when mentioned. + Las menciones ahora se notifican. + No comment provided by engineer. + Good afternoon! ¡Buenas tardes! @@ -3750,12 +4036,17 @@ Error: %2$@ Group will be deleted for all members - this cannot be undone! - El grupo será eliminado para todos los miembros. ¡No podrá deshacerse! + El grupo será eliminado para todos los miembros. ¡No puede deshacerse! No comment provided by engineer. Group will be deleted for you - this cannot be undone! - El grupo será eliminado para tí. ¡No podrá deshacerse! + El grupo será eliminado para tí. ¡No puede deshacerse! + No comment provided by engineer. + + + Groups + Grupos No comment provided by engineer. @@ -3763,6 +4054,11 @@ Error: %2$@ Ayuda No comment provided by engineer. + + Help admins moderating their groups. + Ayuda a los admins a moderar sus grupos. + No comment provided by engineer. + Hidden Oculto @@ -3823,6 +4119,11 @@ Error: %2$@ Cómo ayuda a la privacidad No comment provided by engineer. + + How it works + Cómo funciona + alert button + How to Cómo @@ -3965,6 +4266,16 @@ More improvements are coming soon! Sonido de llamada No comment provided by engineer. + + Inappropriate content + Contenido inapropiado + report reason + + + Inappropriate profile + Perfil inapropiado + report reason + Incognito Incógnito @@ -4057,6 +4368,31 @@ More improvements are coming soon! Colores del interfaz No comment provided by engineer. + + Invalid + No válido + token status text + + + Invalid (bad token) + No válido (token incorrecto) + token status text + + + Invalid (expired) + No válido (expirado) + token status text + + + Invalid (unregistered) + No válido (no registrado) + token status text + + + Invalid (wrong topic) + No válido (tópico incorrecto) + token status text + Invalid QR code Código QR no válido @@ -4330,6 +4666,21 @@ This is your link for group %@! Ordenadores enlazados No comment provided by engineer. + + List + Lista + swipe action + + + List name and emoji should be different for all lists. + El nombre y el emoji deben ser diferentes en todas las listas. + No comment provided by engineer. + + + List name... + Nombre de la lista... + No comment provided by engineer. + Live message! ¡Mensaje en vivo! @@ -4415,11 +4766,20 @@ This is your link for group %@! Miembro No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Miembro inactivo item status text + + Member reports + Informes de miembros + chat feature + Member role will be changed to "%@". All chat members will be notified. El rol del miembro cambiará a "%@" y todos serán notificados. @@ -4437,14 +4797,18 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - El miembro será eliminado del chat. ¡No podrá deshacerse! + El miembro será eliminado del chat. ¡No puede deshacerse! No comment provided by engineer. Member will be removed from group - this cannot be undone! - El miembro será expulsado del grupo. ¡No podrá deshacerse! + El miembro será expulsado del grupo. ¡No puede deshacerse! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Los miembros pueden añadir reacciones a los mensajes. @@ -4455,6 +4819,11 @@ This is your link for group %@! Los miembros del grupo pueden eliminar mensajes de forma irreversible. (24 horas) No comment provided by engineer. + + Members can report messsages to moderators. + Los miembros pueden informar de mensajes a los moderadores. + No comment provided by engineer. + Members can send SimpleX links. Los miembros del grupo pueden enviar enlaces SimpleX. @@ -4480,6 +4849,11 @@ This is your link for group %@! Los miembros del grupo pueden enviar mensajes de voz. No comment provided by engineer. + + Mention members 👋 + Menciona a miembros 👋 + No comment provided by engineer. + Menus Menus @@ -4590,6 +4964,11 @@ This is your link for group %@! ¡Los mensajes de %@ serán mostrados! No comment provided by engineer. + + Messages in this chat will never be deleted. + Los mensajes de esta conversación nunca se eliminan. + alert message + Messages received Mensajes recibidos @@ -4602,7 +4981,7 @@ This is your link for group %@! Messages were deleted after you selected them. - Los mensajes han sido borrados después de seleccionarlos. + Los mensajes han sido eliminados después de seleccionarlos. alert message @@ -4690,6 +5069,11 @@ This is your link for group %@! Moderado: %@ copied message info + + More + Más + swipe action + More improvements are coming soon! ¡Pronto habrá más mejoras! @@ -4718,7 +5102,12 @@ This is your link for group %@! Mute Silenciar - swipe action + notification label action + + + Mute all + Silenciar todo + notification label action Muted when inactive! @@ -4770,6 +5159,11 @@ This is your link for group %@! Estado de la red No comment provided by engineer. + + New + Nuevo + token status text + New Passcode Código Nuevo @@ -4782,7 +5176,7 @@ This is your link for group %@! New SOCKS credentials will be used for each server. - Se usarán credenciales SOCKS nuevas por cada servidor. + Se usarán credenciales SOCKS nuevas para cada servidor. No comment provided by engineer. @@ -4835,6 +5229,10 @@ This is your link for group %@! Nuevo rol de miembro No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Mensaje nuevo @@ -4860,6 +5258,25 @@ This is your link for group %@! Sin contraseña de la aplicación Authentication unavailable + + No chats + Sin chats + No comment provided by engineer. + + + No chats found + Ningún chat encontrado + No comment provided by engineer. + + + No chats in list %@ + Sin chats en la lista %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Ningún contacto seleccionado @@ -4907,12 +5324,17 @@ This is your link for group %@! No media & file servers. - Ningún servidor de archivos y multimedia. + Sin servidores para archivos y multimedia. servers error + + No message + Ningún mensaje + No comment provided by engineer. + No message servers. - Ningún servidor de mensajes. + Sin servidores para mensajes. servers error @@ -4937,7 +5359,7 @@ This is your link for group %@! No push server - Ningún servidor push + Sin servidores push No comment provided by engineer. @@ -4947,24 +5369,34 @@ This is your link for group %@! No servers for private message routing. - Ningún servidor para enrutamiento privado. + Sin servidores para enrutamiento privado. servers error No servers to receive files. - Ningún servidor para recibir archivos. + Sin servidores para recibir archivos. servers error No servers to receive messages. - Ningún servidor para recibir mensajes. + Sin servidores para recibir mensajes. servers error No servers to send files. - Ningún servidor para enviar archivos. + Sin servidores para enviar archivos. servers error + + No token! + ¡Sin token! + alert title + + + No unread chats + Ningún chat sin leer + No comment provided by engineer. + No user identifiers. Sin identificadores de usuario. @@ -4975,6 +5407,11 @@ This is your link for group %@! ¡No compatible! No comment provided by engineer. + + Notes + Notas + No comment provided by engineer. + Nothing selected Nada seleccionado @@ -4995,11 +5432,21 @@ This is your link for group %@! ¡Las notificaciones están desactivadas! No comment provided by engineer. + + Notifications error + Error en notificaciones + alert title + Notifications privacy Privacidad en las notificaciones No comment provided by engineer. + + Notifications status + Estado notificaciones + alert title + Now admins can: - delete members' messages. @@ -5022,7 +5469,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -5065,7 +5513,7 @@ Requiere activación de la VPN. Only delete conversation - Sólo borrar la conversación + Eliminar sólo la conversación No comment provided by engineer. @@ -5083,6 +5531,16 @@ Requiere activación de la VPN. Sólo los propietarios del grupo pueden activar los mensajes de voz. No comment provided by engineer. + + Only sender and moderators see it + Solo el remitente y el moderador pueden verlo + No comment provided by engineer. + + + Only you and moderators see it + Solo tú y los moderadores podéis verlo + No comment provided by engineer. + Only you can add message reactions. Sólo tú puedes añadir reacciones a los mensajes. @@ -5136,7 +5594,7 @@ Requiere activación de la VPN. Open Abrir - No comment provided by engineer. + alert action Open Settings @@ -5168,6 +5626,10 @@ Requiere activación de la VPN. Grupo abierto No comment provided by engineer. + + Open link? + alert title + Open migration to another device Abrir menú migración a otro dispositivo @@ -5210,7 +5672,7 @@ Requiere activación de la VPN. Or show this code - O muestra este código QR + O muestra el código QR No comment provided by engineer. @@ -5218,6 +5680,11 @@ Requiere activación de la VPN. O para compartir en privado No comment provided by engineer. + + Organize chats into lists + Organiza tus chats en listas + No comment provided by engineer. + Other Otro @@ -5409,6 +5876,25 @@ Error: %@ Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Por favor, intenta desactivar y reactivar las notificaciones. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + Por favor, espera a que el token de activación se complete. + token info + + + Please wait for token to be registered. + Por favor, espera a que el token se registre. + token info + Polish interface Interfaz en polaco @@ -5459,16 +5945,31 @@ Error: %@ Privacidad para tus clientes. No comment provided by engineer. + + Privacy policy and conditions of use. + Política de privacidad y condiciones de uso. + No comment provided by engineer. + Privacy redefined Privacidad redefinida No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores. + No comment provided by engineer. + Private filenames Nombres de archivos privados No comment provided by engineer. + + Private media file names. + Nombres privados en archivos de media. + No comment provided by engineer. + Private message routing Enrutamiento privado de mensajes @@ -5544,6 +6045,11 @@ Error: %@ No se permiten reacciones a los mensajes. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No se permite informar de mensajes a los moderadores. + No comment provided by engineer. + Prohibit sending SimpleX links. No se permite enviar enlaces SimpleX. @@ -5576,7 +6082,7 @@ Error: %@ Protect app screen - Proteger la pantalla de la aplicación + Proteger la pantalla No comment provided by engineer. @@ -5638,7 +6144,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reachable chat toolbar - Barra de herramientas accesible + Barra de menú accesible No comment provided by engineer. @@ -5773,7 +6279,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reconnect server to force message delivery. It uses additional traffic. - Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional. + Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional. No comment provided by engineer. @@ -5801,11 +6307,26 @@ Actívalo en ajustes de *Servidores y Redes*. Reducción del uso de batería No comment provided by engineer. + + Register + Registrar + No comment provided by engineer. + + + Register notification token? + ¿Registrar el token de notificaciones? + token info + + + Registered + Registrado + token status text + Reject Rechazar reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5817,6 +6338,10 @@ Actívalo en ajustes de *Servidores y Redes*. Rechazar solicitud de contacto No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. El servidor de retransmisión sólo se usa en caso de necesidad. Un tercero podría ver tu IP. @@ -5902,6 +6427,60 @@ Actívalo en ajustes de *Servidores y Redes*. Responder chat item action + + Report + Informe + chat item action + + + Report content: only group moderators will see it. + Informar de contenido: sólo los moderadores del grupo lo verán. + report reason + + + Report member profile: only group moderators will see it. + Informar del perfil de un miembro: sólo los moderadores del grupo lo verán. + report reason + + + Report other: only group moderators will see it. + Informar de otros: sólo los moderadores del grupo lo verán. + report reason + + + Report reason? + ¿Motivo del informe? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + Informar de spam: sólo los moderadores del grupo lo verán. + report reason + + + Report violation: only group moderators will see it. + Informar de violación: sólo los moderadores del grupo lo verán. + report reason + + + Report: %@ + Informe: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No se permite informar de mensajes a los moderadores. + No comment provided by engineer. + + + Reports + Informes + No comment provided by engineer. + Required Obligatorio @@ -5992,10 +6571,13 @@ Actívalo en ajustes de *Servidores y Redes*. Revisar condiciones No comment provided by engineer. - - Review later - Revisar más tarde - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -6046,13 +6628,17 @@ Actívalo en ajustes de *Servidores y Redes*. Save Guardar alert button - chat item action +chat item action Save (and notify contacts) Guardar (y notificar contactos) alert button + + Save admission settings? + alert title + Save and notify contact Guardar y notificar contacto @@ -6078,6 +6664,11 @@ Actívalo en ajustes de *Servidores y Redes*. Guardar perfil de grupo No comment provided by engineer. + + Save list + Guardar lista + No comment provided by engineer. + Save passphrase and open chat Guardar contraseña y abrir el chat @@ -6175,7 +6766,7 @@ Actívalo en ajustes de *Servidores y Redes*. Scan server QR code - Escanear código QR del servidor + Escanear código QR No comment provided by engineer. @@ -6318,6 +6909,11 @@ Actívalo en ajustes de *Servidores y Redes*. Enviar notificaciones No comment provided by engineer. + + Send private reports + Envía informes privados + No comment provided by engineer. + Send questions and ideas Consultas y sugerencias @@ -6520,7 +7116,7 @@ Actívalo en ajustes de *Servidores y Redes*. Servers statistics will be reset - this cannot be undone! - Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse! + Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse! No comment provided by engineer. @@ -6533,6 +7129,11 @@ Actívalo en ajustes de *Servidores y Redes*. Establecer 1 día No comment provided by engineer. + + Set chat name… + Nombre para el chat… + No comment provided by engineer. + Set contact name… Escribe el nombre del contacto… @@ -6553,6 +7154,15 @@ Actívalo en ajustes de *Servidores y Redes*. Úsalo en lugar de la autenticación del sistema. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + Establece el vencimiento para los mensajes en los chats. + No comment provided by engineer. + Set passcode Código autodestrucción @@ -6597,7 +7207,7 @@ Actívalo en ajustes de *Servidores y Redes*. Share Compartir alert action - chat item action +chat item action Share 1-time link @@ -6641,7 +7251,7 @@ Actívalo en ajustes de *Servidores y Redes*. Share profile - Comparte perfil + Perfil a compartir No comment provided by engineer. @@ -6659,6 +7269,11 @@ Actívalo en ajustes de *Servidores y Redes*. Compartir con contactos No comment provided by engineer. + + Short link + Enlace corto + No comment provided by engineer. + Show QR code Mostrar código QR @@ -6716,7 +7331,7 @@ Actívalo en ajustes de *Servidores y Redes*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. - Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación + Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. No comment provided by engineer. @@ -6751,7 +7366,7 @@ Actívalo en ajustes de *Servidores y Redes*. SimpleX address and 1-time links are safe to share via any messenger. - Compartir los enlaces de un uso y las direcciones SimpleX es seguro a través de cualquier medio. + Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. No comment provided by engineer. @@ -6759,6 +7374,11 @@ Actívalo en ajustes de *Servidores y Redes*. ¿Dirección SimpleX o enlace de un uso? No comment provided by engineer. + + SimpleX channel link + Enlace de canal SimpleX + simplex link type + SimpleX contact address Dirección de contacto SimpleX @@ -6821,7 +7441,7 @@ Actívalo en ajustes de *Servidores y Redes*. Small groups (max 20) - Grupos pequeños (máx. 20) + Grupos pequeños (max. 20) No comment provided by engineer. @@ -6861,6 +7481,12 @@ Actívalo en ajustes de *Servidores y Redes*. Alguien notification title + + Spam + Spam + blocking reason +report reason + Square, circle, or anything in between. Cuadrada, circular o cualquier forma intermedia. @@ -6908,7 +7534,7 @@ Actívalo en ajustes de *Servidores y Redes*. Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. - Para poder exportar, importar o eliminar la base de datos primero debes parar SimpleX. Mientras tanto no podrás recibir ni enviar mensajes. + Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. No comment provided by engineer. @@ -6946,6 +7572,11 @@ Actívalo en ajustes de *Servidores y Redes*. Parando chat No comment provided by engineer. + + Storage + Almacenamiento + No comment provided by engineer. + Strong Fuerte @@ -6958,7 +7589,7 @@ Actívalo en ajustes de *Servidores y Redes*. Subscribed - Suscrito + Suscritas No comment provided by engineer. @@ -7006,6 +7637,11 @@ Actívalo en ajustes de *Servidores y Redes*. Timeout de la conexión TCP No comment provided by engineer. + + TCP port for messaging + Puerto TCP para mensajes + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7074,13 +7710,18 @@ Actívalo en ajustes de *Servidores y Redes*. Temporary file error Error en archivo temporal - No comment provided by engineer. + file error alert title Test failed at step %@. Prueba no superada en el paso %@. server test failure + + Test notifications + Probar notificaciones + No comment provided by engineer. + Test server Probar servidor @@ -7140,7 +7781,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The code you scanned is not a SimpleX link QR code. - El código QR escaneado no es un enlace SimpleX. + El código QR escaneado no es un enlace de SimpleX. No comment provided by engineer. @@ -7203,21 +7844,11 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. La base de datos antigua no se eliminó durante la migración, puede eliminarse. No comment provided by engineer. - - The profile is only shared with your contacts. - El perfil sólo se comparte con tus contactos. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Las mismas condiciones se aplicarán al operador **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Las mismas condiciones se aplicarán a el/los operador(es) **%@**. - No comment provided by engineer. - The second preset operator in the app! ¡Segundo operador predefinido! @@ -7235,17 +7866,17 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The servers for new connections of your current chat profile **%@**. - Lista de servidores para las conexiones nuevas del perfil **%@**. + Servidores para conexiones nuevas en tu perfil **%@**. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - Los servidores para archivos nuevos en tu perfil actual **%@**. + Servidores para enviar archivos en tu perfil **%@**. No comment provided by engineer. The text you pasted is not a SimpleX link. - El texto pegado no es un enlace SimpleX. + El texto pegado no es un enlace de SimpleX. No comment provided by engineer. @@ -7283,6 +7914,11 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Esta acción es irreversible. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Podría tardar varios minutos. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No puede deshacerse! + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente. @@ -7328,11 +7964,21 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. ¡Este es tu propio enlace de un solo uso! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador. No comment provided by engineer. + + This message was deleted or not received yet. + El mensaje ha sido eliminado o aún no se ha recibido. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Esta configuración se aplica a los mensajes del perfil actual **%@**. @@ -7387,7 +8033,7 @@ Se te pedirá que completes la autenticación antes de activar esta función.
To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Para proteger tu privacidad, en lugar de los identificadores de usuario que usan el resto de plataformas, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos. + Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos. No comment provided by engineer. @@ -7427,7 +8073,7 @@ Se te pedirá que completes la autenticación antes de activar esta función.
To use the servers of **%@**, accept conditions of use. - Para usar los servidores de **%@**, acepta las condiciones de uso. + Para usar los servidores de **%@**, debes aceptar las condiciones de uso. No comment provided by engineer. @@ -7445,6 +8091,11 @@ Se te pedirá que completes la autenticación antes de activar esta función.
Activa incógnito al conectar. No comment provided by engineer. + + Token status: %@. + Estado token: %@. + token status + Toolbar opacity Opacidad barra @@ -7512,7 +8163,7 @@ Se te pedirá que completes la autenticación antes de activar esta función.
Unblock member for all? - ¿Desbloquear miembro para todos? + ¿Desbloquear el miembro para todos? No comment provided by engineer. @@ -7583,7 +8234,7 @@ Se te pedirá que completes la autenticación antes de activar esta función. Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. - A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo. + A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo. Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. No comment provided by engineer. @@ -7610,13 +8261,18 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Unmute Activar audio - swipe action + notification label action Unread No leído swipe action + + Unsupported connection link + Enlace de conexión no compatible + No comment provided by engineer. + Up to 100 last messages are sent to new members. Hasta 100 últimos mensajes son enviados a los miembros nuevos. @@ -7642,9 +8298,14 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión ¿Actualizar configuración? No comment provided by engineer. + + Updated conditions + Condiciones actualizadas + No comment provided by engineer. + Updating settings will re-connect the client to all servers. - Al actualizar la configuración el cliente se reconectará a todos los servidores. + Para actualizar la configuración el cliente se reconectará a todos los servidores. No comment provided by engineer. @@ -7702,6 +8363,16 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión ¿Usar servidores SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + Se usa el puerto TCP %@ cuando no se ha especificado otro. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + Usar puerto TCP 443 solo en servidores predefinidos. + No comment provided by engineer. + Use chat Usar Chat @@ -7714,17 +8385,17 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use for files - Usar para archivos + Uso para archivos No comment provided by engineer. Use for messages - Usar para mensajes + Uso para mensajes No comment provided by engineer. Use for new connections - Usar para conexiones nuevas + Para conexiones nuevas No comment provided by engineer. @@ -7754,7 +8425,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use private routing with unknown servers. - Usar enrutamiento privado con servidores de retransmisión desconocidos. + Usar enrutamiento privado con servidores de mensaje desconocidos. No comment provided by engineer. @@ -7767,6 +8438,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usar servidores No comment provided by engineer. + + Use short links (BETA) + Usar enlaces cortos (BETA) + No comment provided by engineer. + Use the app while in the call. Usar la aplicación durante la llamada. @@ -7777,6 +8453,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usa la aplicación con una sola mano. No comment provided by engineer. + + Use web port + Usar puerto web + No comment provided by engineer. + User selection Selección de usuarios @@ -8136,7 +8817,7 @@ Repeat join request? You are not connected to these servers. Private routing is used to deliver messages to them. - No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado. + No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. No comment provided by engineer. @@ -8149,11 +8830,6 @@ Repeat join request? Puedes cambiar la posición de la barra desde el menú Apariencia. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Puedes configurar los operadores desde Servidores y Redes. - No comment provided by engineer. - You can configure servers via settings. Puedes configurar los servidores a través de su configuración. @@ -8244,6 +8920,10 @@ Repeat join request? Podrás ver el enlace de invitación en detalles de conexión. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! ¡No puedes enviar mensajes! @@ -8308,7 +8988,7 @@ Repeat connection request? You need to allow your contact to call to be able to call them. - Necesitas permitir que tus contacto llamen para poder llamarles. + Debes permitir que tus contacto te llamen para poder llamarles. No comment provided by engineer. @@ -8326,6 +9006,11 @@ Repeat connection request? Has enviado una invitación de grupo No comment provided by engineer. + + You should receive notifications. + Deberías recibir notificaciones. + token info + You will be connected to group when the group host's device is online, please wait or check later! Te conectarás al grupo cuando el dispositivo del anfitrión esté en línea, por favor espera o revisa más tarde. @@ -8391,11 +9076,6 @@ Repeat connection request? Servidores ICE No comment provided by engineer. - - Your SMP servers - Servidores SMP - No comment provided by engineer. - Your SimpleX address Mi dirección SimpleX @@ -8481,6 +9161,11 @@ Repeat connection request? El perfil **%@** será compartido. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + El perfil sólo se comparte con tus contactos. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Tu perfil es almacenado en tu dispositivo y solamente se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. @@ -8491,11 +9176,6 @@ Repeat connection request? Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos. alert message - - Your profile, contacts and delivered messages are stored on your device. - Tu perfil, contactos y mensajes se almacenan en tu dispositivo. - No comment provided by engineer. - Your random profile Tu perfil aleatorio @@ -8546,6 +9226,10 @@ Repeat connection request? y después elige: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call llamada aceptada @@ -8556,6 +9240,10 @@ Repeat connection request? invitación aceptada chat list item title + + accepted you + rcv group event chat item + admin administrador @@ -8576,6 +9264,10 @@ Repeat connection request? acordando cifrado… chat item text + + all + member criteria value + all members todos los miembros @@ -8591,6 +9283,11 @@ Repeat connection request? y %lld evento(s) más No comment provided by engineer. + + archived report + informes archivados + No comment provided by engineer. + attempts intentos @@ -8629,7 +9326,8 @@ Repeat connection request? blocked by admin bloqueado por administrador - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8656,6 +9354,10 @@ Repeat connection request? llamando… call status + + can't send messages + No comment provided by engineer. + cancelled %@ cancelado %@ @@ -8713,7 +9415,7 @@ Repeat connection request? connecting - conectando + conectando... No comment provided by engineer. @@ -8761,6 +9463,14 @@ Repeat connection request? el contacto %1$@ ha cambiado a %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption el contacto dispone de cifrado de extremo a extremo @@ -8771,6 +9481,10 @@ Repeat connection request? el contacto no dispone de cifrado de extremo a extremo No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator creador @@ -8799,7 +9513,8 @@ Repeat connection request? default (%@) predeterminado (%@) - pref value + delete after time +pref value default (no) @@ -8823,7 +9538,7 @@ Repeat connection request? deleted group - grupo eliminado + ha eliminado el grupo rcv group event chat item @@ -8926,21 +9641,11 @@ Repeat connection request? error No comment provided by engineer. - - event happened - evento ocurrido - No comment provided by engineer. - expired expirados No comment provided by engineer. - - for better metadata privacy. - para mejorar la privacidad de los metadatos. - No comment provided by engineer. - forwarded reenviado @@ -8951,6 +9656,10 @@ Repeat connection request? grupo eliminado No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated perfil de grupo actualizado @@ -9076,6 +9785,10 @@ Repeat connection request? conectado rcv group event chat item + + member has old version + No comment provided by engineer. + message mensaje @@ -9106,20 +9819,20 @@ Repeat connection request? moderado por %@ marked deleted chat item preview text + + moderator + moderador + member role + months meses time unit - - mute - silenciar - No comment provided by engineer. - never nunca - No comment provided by engineer. + delete after time new message @@ -9141,6 +9854,10 @@ Repeat connection request? sin texto copied message info in history + + not synchronized + No comment provided by engineer. + observer observador @@ -9150,8 +9867,9 @@ Repeat connection request? off desactivado enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9193,6 +9911,20 @@ Repeat connection request? p2p No comment provided by engineer. + + pending + pendiente + No comment provided by engineer. + + + pending approval + pendiente de aprobación + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption cifrado e2e resistente a tecnología cuántica @@ -9208,6 +9940,11 @@ Repeat connection request? confirmación recibida… No comment provided by engineer. + + rejected + rechazado + No comment provided by engineer. + rejected call llamada rechazada @@ -9228,6 +9965,10 @@ Repeat connection request? dirección de contacto eliminada profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture ha eliminado la imagen del perfil @@ -9238,11 +9979,23 @@ Repeat connection request? te ha expulsado rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect solicitado para conectar chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved guardado @@ -9342,11 +10095,6 @@ last received msg: %2$@ estado desconocido No comment provided by engineer. - - unmute - activar sonido - No comment provided by engineer. - unprotected con IP desprotegida @@ -9437,6 +10185,10 @@ last received msg: %2$@ tu No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group has sido invitado a un grupo @@ -9511,7 +10263,7 @@ last received msg: %2$@
- +
@@ -9548,7 +10300,7 @@ last received msg: %2$@
- +
@@ -9570,7 +10322,7 @@ last received msg: %2$@
- +
@@ -9578,6 +10330,11 @@ last received msg: %2$@ %d evento(s) nuevo(s) notification body + + From %d chat(s) + De %d chat(s) + notification body + From: %@ De: %@ @@ -9593,16 +10350,11 @@ last received msg: %2$@ Mensajes nuevos notification - - New messages in %d chats - Mensajes nuevos en %d chat(s) - notification body -
- +
@@ -9624,7 +10376,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/es.xcloc/contents.json b/apps/ios/SimpleX Localizations/es.xcloc/contents.json index 340591e607..80cffac8d2 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/es.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "es", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 2caa98e25b..7c93c5b0bb 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (voidaan kopioida) @@ -189,6 +177,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d ohitettua viestiä @@ -254,11 +246,6 @@ %lld uutta käyttöliittymän kieltä No comment provided by engineer. - - %lld second(s) - %lld sekunti(a) - No comment provided by engineer. - %lld seconds %lld sekuntia @@ -309,11 +296,6 @@ %u viestit ohitettu. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) No comment provided by engineer. @@ -322,11 +304,6 @@ (this device v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. No comment provided by engineer. @@ -387,11 +364,6 @@ \*bold* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -422,11 +394,6 @@ - historian muokkaaminen. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec time to disappear @@ -439,7 +406,8 @@ 1 day 1 päivä - time interval + delete after time +time interval 1 hour @@ -454,12 +422,18 @@ 1 month 1 kuukausi - time interval + delete after time +time interval 1 week 1 viikko - time interval + delete after time +time interval + + + 1 year + delete after time 1-time link @@ -484,11 +458,6 @@ 30 sekuntia No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -555,8 +524,16 @@ Accept Hyväksy accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions @@ -576,7 +553,11 @@ Accept incognito Hyväksy tuntematon accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions @@ -590,6 +571,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -603,6 +588,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Lisää profiili @@ -627,6 +616,10 @@ Lisää toiseen laitteeseen No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Lisää tervetuloviesti @@ -692,6 +685,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Kaikki sovelluksen tiedot poistetaan. @@ -702,6 +699,10 @@ Kaikki keskustelut ja viestit poistetaan - tätä ei voi kumota! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. Kaikki tiedot poistetaan, kun se syötetään. @@ -737,6 +738,14 @@ All profiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Kaikki kontaktisi pysyvät yhteydessä. @@ -808,6 +817,10 @@ Salli lähetettyjen viestien peruuttamaton poistaminen. (24 tuntia) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. No comment provided by engineer. @@ -884,6 +897,10 @@ Luodaan tyhjä chat-profiili annetulla nimellä, ja sovellus avautuu normaalisti. No comment provided by engineer. + + Another reason + report reason + Answer call Vastaa puheluun @@ -907,6 +924,10 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Sovelluksen kuvake @@ -949,6 +970,18 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -957,6 +990,18 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1060,6 +1105,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1077,6 +1126,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -1162,11 +1215,21 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Chat-profiilin mukaan (oletus) tai [yhteyden mukaan](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Puhelu on jo päättynyt! @@ -1211,7 +1274,7 @@ Cancel Peruuta alert action - alert button +alert button Cancel migration @@ -1244,6 +1307,10 @@ Muuta No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1292,7 +1359,7 @@ Change self-destruct passcode Vaihda itsetuhoutuva pääsykoodi authentication reason - set passcode view +set passcode view Chat @@ -1382,11 +1449,23 @@ Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Keskustelut No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1446,6 +1525,14 @@ Tyhjennä keskustelu? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1463,6 +1550,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Vertaa tiedostoa @@ -1493,14 +1584,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1518,6 +1601,10 @@ Määritä ICE-palvelimet No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Vahvista @@ -1563,6 +1650,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect Yhdistä @@ -1664,6 +1755,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Yhteysvirhe @@ -1674,6 +1769,15 @@ This is your own one-time link! Yhteysvirhe (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1683,6 +1787,10 @@ This is your own one-time link! Yhteyspyyntö lähetetty! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1756,6 +1864,10 @@ This is your own one-time link! Kontaktit voivat merkitä viestit poistettaviksi; voit katsella niitä. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Jatka @@ -1824,6 +1936,10 @@ This is your own one-time link! Luo linkki No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻 @@ -1831,6 +1947,7 @@ This is your own one-time link! Create profile + Luo profiilisi No comment provided by engineer. @@ -2029,8 +2146,7 @@ This is your own one-time link! Delete Poista alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2068,6 +2184,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Poista keskusteluprofiili @@ -2078,6 +2198,10 @@ This is your own one-time link! Poista keskusteluprofiili? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2155,6 +2279,10 @@ This is your own one-time link! Poista linkki? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Poista jäsenviesti? @@ -2168,7 +2296,7 @@ This is your own one-time link! Delete messages Poista viestit - No comment provided by engineer. + alert button Delete messages after @@ -2204,6 +2332,10 @@ This is your own one-time link! Poista jono server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2349,6 +2481,14 @@ This is your own one-time link! Poista SimpleX Lock käytöstä authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Poista käytöstä kaikilta @@ -2432,6 +2572,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Älä luo osoitetta @@ -2442,9 +2586,17 @@ This is your own one-time link! Älä salli No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Älä näytä uudelleen + alert action + + + Done No comment provided by engineer. @@ -2455,7 +2607,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2524,8 +2676,8 @@ This is your own one-time link! Salli (pidä ohitukset) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2541,7 +2693,7 @@ This is your own one-time link! Enable automatic message deletion? Ota automaattinen viestien poisto käyttöön? - No comment provided by engineer. + alert title Enable camera access @@ -2660,6 +2812,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Syötä pääsykoodi @@ -2730,6 +2886,10 @@ This is your own one-time link! Virhe kontaktipyynnön hyväksymisessä No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Virhe lisättäessä jäseniä @@ -2762,6 +2922,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -2781,6 +2945,10 @@ This is your own one-time link! Virhe ryhmälinkin luomisessa No comment provided by engineer. + + Error creating list + alert title + Error creating member contact No comment provided by engineer. @@ -2794,6 +2962,10 @@ This is your own one-time link! Virhe profiilin luomisessa! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Virhe tiedoston salauksen purussa @@ -2804,6 +2976,10 @@ This is your own one-time link! Virhe keskustelujen tietokannan poistamisessa No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Virhe keskutelun poistamisessa! @@ -2897,10 +3073,18 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Virhe poistettaessa jäsentä - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -2911,6 +3095,10 @@ This is your own one-time link! Virhe ICE-palvelimien tallentamisessa No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Virhe ryhmäprofiilin tallentamisessa @@ -2986,6 +3174,10 @@ This is your own one-time link! Virhe yhteyden synkronoinnissa No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Virhe ryhmälinkin päivittämisessä @@ -3026,7 +3218,9 @@ This is your own one-time link! Error: %@ Virhe: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3060,6 +3254,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database Vie tietokanta @@ -3098,24 +3296,41 @@ This is your own one-time link! Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Suosikki swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3233,6 +3448,10 @@ This is your own one-time link! Ryhmän jäsen ei tue korjausta No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3246,6 +3465,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -3350,6 +3573,10 @@ Error: %2$@ GIFit ja tarrat No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3451,11 +3678,19 @@ Error: %2$@ Ryhmä poistetaan sinulta - tätä ei voi perua! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Apua No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Piilotettu @@ -3513,6 +3748,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Miten @@ -3645,6 +3884,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -3734,6 +3981,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -3985,6 +4252,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Live-viesti! @@ -4068,10 +4347,18 @@ This is your link for group %@! Jäsen No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4095,6 +4382,10 @@ This is your link for group %@! Jäsen poistetaan ryhmästä - tätä ei voi perua! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Ryhmän jäsenet voivat lisätä viestireaktioita. @@ -4105,6 +4396,10 @@ This is your link for group %@! Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti. (24 tuntia) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -4129,6 +4424,10 @@ This is your link for group %@! Ryhmän jäsenet voivat lähettää ääniviestejä. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4226,6 +4525,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4314,6 +4617,10 @@ This is your link for group %@! Moderoitu klo: %@ copied message info + + More + swipe action + More improvements are coming soon! Lisää parannuksia on tulossa pian! @@ -4340,7 +4647,11 @@ This is your link for group %@! Mute Mykistä - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4387,6 +4698,10 @@ This is your link for group %@! Verkon tila No comment provided by engineer. + + New + token status text + New Passcode Uusi pääsykoodi @@ -4445,6 +4760,10 @@ This is your link for group %@! Uusi jäsenrooli No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Uusi viesti @@ -4469,6 +4788,22 @@ This is your link for group %@! Ei sovelluksen salasanaa Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Kontakteja ei ole valittu @@ -4516,6 +4851,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -4563,6 +4902,14 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. @@ -4572,6 +4919,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4590,10 +4941,18 @@ This is your link for group %@! Ilmoitukset on poistettu käytöstä! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4615,7 +4974,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -4674,6 +5034,14 @@ Edellyttää VPN:n sallimista. Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Vain sinä voit lisätä viestireaktioita. @@ -4726,7 +5094,7 @@ Edellyttää VPN:n sallimista. Open - No comment provided by engineer. + alert action Open Settings @@ -4755,6 +5123,10 @@ Edellyttää VPN:n sallimista. Open group No comment provided by engineer. + + Open link? + alert title + Open migration to another device authentication reason @@ -4795,6 +5167,10 @@ Edellyttää VPN:n sallimista. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -4969,6 +5345,22 @@ Error: %@ Säilytä tunnuslause turvallisesti, ET voi muuttaa sitä, jos kadotat sen. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Puolalainen käyttöliittymä @@ -5015,16 +5407,28 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Yksityisyys uudelleen määritettynä No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Yksityiset tiedostonimet No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -5093,6 +5497,10 @@ Error: %@ Estä viestireaktiot. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5328,11 +5736,23 @@ Enable in *Network & servers* settings. Pienempi akun käyttö No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Hylkää reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5344,6 +5764,10 @@ Enable in *Network & servers* settings. Hylkää yhteyspyyntö No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Välityspalvelinta käytetään vain tarvittaessa. Toinen osapuoli voi tarkkailla IP-osoitettasi. @@ -5422,6 +5846,50 @@ Enable in *Network & servers* settings. Vastaa chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required Pakollinen @@ -5505,9 +5973,13 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5554,13 +6026,17 @@ Enable in *Network & servers* settings. Save Tallenna alert button - chat item action +chat item action Save (and notify contacts) Tallenna (ja ilmoita kontakteille) alert button + + Save admission settings? + alert title + Save and notify contact Tallenna ja ilmoita kontaktille @@ -5585,6 +6061,10 @@ Enable in *Network & servers* settings. Tallenna ryhmäprofiili No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Tallenna tunnuslause ja avaa keskustelu @@ -5805,6 +6285,10 @@ Enable in *Network & servers* settings. Lähetys ilmoitukset No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Lähetä kysymyksiä ja ideoita @@ -6000,6 +6484,10 @@ Enable in *Network & servers* settings. Aseta 1 päivä No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Aseta kontaktin nimi… @@ -6019,6 +6507,14 @@ Enable in *Network & servers* settings. Aseta se järjestelmän todennuksen sijaan. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Aseta pääsykoodi @@ -6060,7 +6556,7 @@ Enable in *Network & servers* settings. Share Jaa alert action - chat item action +chat item action Share 1-time link @@ -6115,6 +6611,10 @@ Enable in *Network & servers* settings. Jaa kontaktien kanssa No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6207,6 +6707,10 @@ Enable in *Network & servers* settings. SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX-yhteystiedot @@ -6298,6 +6802,11 @@ Enable in *Network & servers* settings. Joku notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6377,6 +6886,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6430,6 +6943,10 @@ Enable in *Network & servers* settings. TCP-yhteyden aikakatkaisu No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6492,13 +7009,17 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. Testi epäonnistui vaiheessa %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Testipalvelin @@ -6615,19 +7136,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa. No comment provided by engineer. - - The profile is only shared with your contacts. - Profiili jaetaan vain kontaktiesi kanssa. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -6687,6 +7199,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti. @@ -6726,10 +7242,18 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.This is your own one-time link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. @@ -6831,6 +7355,10 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -6983,13 +7511,17 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Unmute Poista mykistys - swipe action + notification label action Unread Lukematon swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -7013,6 +7545,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Asetusten päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin. @@ -7066,6 +7602,14 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Käytä SimpleX Chat palvelimia? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Käytä chattia @@ -7124,6 +7668,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. @@ -7132,6 +7680,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7463,10 +8015,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -7550,6 +8098,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Et voi lähettää viestejä! @@ -7626,6 +8178,10 @@ Repeat connection request? Lähetit ryhmäkutsun No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin! @@ -7688,11 +8244,6 @@ Repeat connection request? ICE-palvelimesi No comment provided by engineer. - - Your SMP servers - SMP-palvelimesi - No comment provided by engineer. - Your SimpleX address SimpleX-osoitteesi @@ -7774,6 +8325,11 @@ Repeat connection request? Profiilisi **%@** jaetaan. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Profiili jaetaan vain kontaktiesi kanssa. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. SimpleX-palvelimet eivät näe profiiliasi. @@ -7783,11 +8339,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi. - No comment provided by engineer. - Your random profile Satunnainen profiilisi @@ -7837,6 +8388,10 @@ Repeat connection request? edellä, valitse sitten: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call hyväksytty puhelu @@ -7846,6 +8401,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin ylläpitäjä @@ -7865,6 +8424,10 @@ Repeat connection request? hyväksyy salausta… chat item text + + all + member criteria value + all members feature role @@ -7878,6 +8441,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -7911,7 +8478,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -7937,6 +8505,10 @@ Repeat connection request? soittaa… call status + + can't send messages + No comment provided by engineer. + cancelled %@ peruutettu %@ @@ -8040,6 +8612,14 @@ Repeat connection request? contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kontaktilla on e2e-salaus @@ -8050,6 +8630,10 @@ Repeat connection request? kontaktilla ei ole e2e-salausta No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator luoja @@ -8077,7 +8661,8 @@ Repeat connection request? default (%@) oletusarvo (%@) - pref value + delete after time +pref value default (no) @@ -8202,19 +8787,10 @@ Repeat connection request? virhe No comment provided by engineer. - - event happened - tapahtuma tapahtui - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8224,6 +8800,10 @@ Repeat connection request? ryhmä poistettu No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated ryhmäprofiili päivitetty @@ -8346,6 +8926,10 @@ Repeat connection request? yhdistetty rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8375,19 +8959,19 @@ Repeat connection request? %@ moderoi marked deleted chat item preview text + + moderator + member role + months kuukautta time unit - - mute - No comment provided by engineer. - never ei koskaan - No comment provided by engineer. + delete after time new message @@ -8409,6 +8993,10 @@ Repeat connection request? ei tekstiä copied message info in history + + not synchronized + No comment provided by engineer. + observer tarkkailija @@ -8418,8 +9006,9 @@ Repeat connection request? off pois enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -8458,6 +9047,18 @@ Repeat connection request? vertais No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8472,6 +9073,10 @@ Repeat connection request? vahvistus saatu… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call hylätty puhelu @@ -8491,6 +9096,10 @@ Repeat connection request? removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -8500,10 +9109,22 @@ Repeat connection request? poisti sinut rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -8590,10 +9211,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. @@ -8678,6 +9295,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group sinut on kutsuttu ryhmään @@ -8750,7 +9371,7 @@ last received msg: %2$@
- +
@@ -8786,7 +9407,7 @@ last received msg: %2$@
- +
@@ -8808,13 +9429,17 @@ last received msg: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -8827,15 +9452,11 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -8854,7 +9475,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json index 78ce40cec5..11f7a4861c 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "fi", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 148156b07c..80b3428cfe 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (peut être copié) @@ -114,10 +102,12 @@ %@ server + Serveur %@ No comment provided by engineer. %@ servers + Serveurs %@ No comment provided by engineer. @@ -200,6 +190,11 @@ %d sec time interval + + %d seconds(s) + %d seconde(s) + delete after time + %d skipped message(s) %d message·s sauté·s @@ -270,11 +265,6 @@ %lld nouvelles langues d'interface No comment provided by engineer. - - %lld second(s) - %lld seconde·s - No comment provided by engineer. - %lld seconds %lld secondes @@ -325,11 +315,6 @@ %u messages sautés. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nouveau) @@ -340,11 +325,6 @@ (cet appareil v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Ajouter un contact** : pour créer un nouveau lien d'invitation. @@ -382,6 +362,7 @@ **Scan / Paste link**: to connect via a link you received. + **Scanner / Coller** : pour vous connecter via un lien que vous avez reçu. No comment provided by engineer. @@ -409,11 +390,6 @@ \*gras* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -450,11 +426,6 @@ - l'historique de modification. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -468,7 +439,8 @@ 1 day 1 jour - time interval + delete after time +time interval 1 hour @@ -483,19 +455,28 @@ 1 month 1 mois - time interval + delete after time +time interval 1 week 1 semaine - time interval + delete after time +time interval + + + 1 year + 1 an + delete after time 1-time link + Lien unique No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Le lien unique peut être utilisé *avec un seul contact* - partagez le en personne ou via n'importe quelle messagerie. No comment provided by engineer. @@ -513,11 +494,6 @@ 30 secondes No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -574,6 +550,7 @@ About operators + À propos des opérateurs No comment provided by engineer. @@ -585,11 +562,20 @@ Accept Accepter accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions + Accepter les conditions No comment provided by engineer. @@ -606,10 +592,15 @@ Accept incognito Accepter en incognito accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions + Conditions acceptées No comment provided by engineer. @@ -622,6 +613,11 @@ Erreur d'accusé de réception No comment provided by engineer. + + Active + Actif + token status text + Active connections Connections actives @@ -634,6 +630,12 @@ Add friends + Ajouter des amis + No comment provided by engineer. + + + Add list + Ajouter une liste No comment provided by engineer. @@ -653,6 +655,7 @@ Add team members + Ajouter des membres à l'équipe No comment provided by engineer. @@ -660,6 +663,11 @@ Ajouter à un autre appareil No comment provided by engineer. + + Add to list + Ajouter à la liste + No comment provided by engineer. + Add welcome message Ajouter un message d'accueil @@ -667,14 +675,17 @@ Add your team members to the conversations. + Ajoutez les membres de votre équipe aux conversations. No comment provided by engineer. Added media & file servers + Ajout de serveurs de médias et de fichiers No comment provided by engineer. Added message servers + Ajout de serveurs de messages No comment provided by engineer. @@ -704,10 +715,12 @@ Address or 1-time link? + Adresse ou lien unique ? No comment provided by engineer. Address settings + Paramètres de l'adresse No comment provided by engineer. @@ -730,6 +743,11 @@ Paramètres avancés No comment provided by engineer. + + All + Tout + No comment provided by engineer. + All app data is deleted. Toutes les données de l'application sont supprimées. @@ -740,6 +758,11 @@ Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière ! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Tous les chats seront supprimés de la liste %@, et la liste sera supprimée. + alert message + All data is erased when it is entered. Toutes les données sont effacées lorsqu'il est saisi. @@ -757,6 +780,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs. No comment provided by engineer. @@ -779,6 +803,15 @@ Tous les profiles profile dropdown + + All reports will be archived for you. + Tous les rapports seront archivés pour vous. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Tous vos contacts resteront connectés. @@ -854,6 +887,11 @@ Autoriser la suppression irréversible de messages envoyés. (24 heures) No comment provided by engineer. + + Allow to report messsages to moderators. + Permettre de signaler des messages aux modérateurs. + No comment provided by engineer. + Allow to send SimpleX links. Autorise l'envoi de liens SimpleX. @@ -934,6 +972,11 @@ Un profil de chat vierge portant le nom fourni est créé et l'application s'ouvre normalement. No comment provided by engineer. + + Another reason + Autre raison + report reason + Answer call Répondre à l'appel @@ -959,6 +1002,10 @@ L'application chiffre les nouveaux fichiers locaux (sauf les vidéos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Icône de l'app @@ -1004,6 +1051,21 @@ Appliquer à No comment provided by engineer. + + Archive + Archiver + No comment provided by engineer. + + + Archive %lld reports? + Archiver les rapports %lld ? + No comment provided by engineer. + + + Archive all reports? + Archiver tous les rapports ? + No comment provided by engineer. + Archive and upload Archiver et téléverser @@ -1014,6 +1076,21 @@ Archiver les contacts pour discuter plus tard. No comment provided by engineer. + + Archive report + Archiver le rapport + No comment provided by engineer. + + + Archive report? + Archiver le rapport ? + No comment provided by engineer. + + + Archive reports + Archiver les rapports + swipe action + Archived contacts Contacts archivés @@ -1124,6 +1201,11 @@ Des groupes plus performants No comment provided by engineer. + + Better groups performance + Meilleure performance des groupes + No comment provided by engineer. + Better message dates. Meilleures dates de messages. @@ -1144,6 +1226,11 @@ Notifications améliorées No comment provided by engineer. + + Better privacy and security + Meilleure protection de la privacité et de la sécurité + No comment provided by engineer. + Better security ✅ Sécurité accrue ✅ @@ -1236,10 +1323,17 @@ Business address + Adresse professionnelle No comment provided by engineer. Business chats + Discussions professionnelles + No comment provided by engineer. + + + Businesses + Entreprises No comment provided by engineer. @@ -1247,6 +1341,15 @@ Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + En utilisant SimpleX Chat, vous acceptez de : +- n'envoyer que du contenu légal dans les groupes publics. +- respecter les autres utilisateurs - pas de spam. + No comment provided by engineer. + Call already ended! Appel déjà terminé ! @@ -1296,7 +1399,7 @@ Cancel Annuler alert action - alert button +alert button Cancel migration @@ -1333,8 +1436,14 @@ Changer No comment provided by engineer. + + Change automatic message deletion? + Modifier la suppression automatique des messages ? + alert title + Change chat profiles + Changer de profil de discussion authentication reason @@ -1381,18 +1490,21 @@ Change self-destruct passcode Modifier le code d'autodestruction authentication reason - set passcode view +set passcode view Chat + Discussions No comment provided by engineer. Chat already exists + La discussion existe déjà No comment provided by engineer. Chat already exists! + La discussion existe déjà ! No comment provided by engineer. @@ -1472,10 +1584,20 @@ Chat will be deleted for all members - this cannot be undone! + La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière ! + No comment provided by engineer. + + + Chat with admins + chat toolbar + + + Chat with member No comment provided by engineer. @@ -1483,12 +1605,18 @@ Discussions No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. + Consulter les messages toutes les 20 minutes. No comment provided by engineer. Check messages when allowed. + Consulter les messages quand c'est possible. No comment provided by engineer. @@ -1546,6 +1674,16 @@ Effacer la conversation ? No comment provided by engineer. + + Clear group? + Vider le groupe ? + No comment provided by engineer. + + + Clear or delete group? + Vider ou supprimer le groupe ? + No comment provided by engineer. + Clear private notes? Effacer les notes privées ? @@ -1566,6 +1704,11 @@ Mode de couleur No comment provided by engineer. + + Community guidelines violation + Infraction aux règles communautaires + report reason + Compare file Comparer le fichier @@ -1583,38 +1726,37 @@ Conditions accepted on: %@. + Conditions acceptées le : %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Les conditions sont acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Les conditions sont déjà acceptées pour ces opérateurs : **%@**. No comment provided by engineer. Conditions of use - No comment provided by engineer. - - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. + Conditions d'utilisation No comment provided by engineer. Conditions will be accepted for the operator(s): **%@**. + Les conditions seront acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Les conditions seront acceptées le : %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Les conditions seront automatiquement acceptées pour les opérateurs activés le : %@. No comment provided by engineer. @@ -1622,6 +1764,11 @@ Configurer les serveurs ICE No comment provided by engineer. + + Configure server operators + Configurer les opérateurs de serveur + No comment provided by engineer. + Confirm Confirmer @@ -1672,6 +1819,11 @@ Confirmer la transmission No comment provided by engineer. + + Confirmed + Confirmé + token status text + Connect Se connecter @@ -1791,6 +1943,11 @@ Il s'agit de votre propre lien unique ! État de la connexion et des serveurs. No comment provided by engineer. + + Connection blocked + Connexion bloquée + No comment provided by engineer. + Connection error Erreur de connexion @@ -1801,6 +1958,18 @@ Il s'agit de votre propre lien unique ! Erreur de connexion (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + La connexion est bloquée par l'opérateur du serveur : +%@ + No comment provided by engineer. + + + Connection not ready. + La connexion n'est pas prête. + No comment provided by engineer. + Connection notifications Notifications de connexion @@ -1811,8 +1980,14 @@ Il s'agit de votre propre lien unique ! Demande de connexion envoyée ! No comment provided by engineer. + + Connection requires encryption renegotiation. + La connexion nécessite une renégociation du cryptage. + No comment provided by engineer. + Connection security + Sécurité des connexions No comment provided by engineer. @@ -1890,6 +2065,11 @@ Il s'agit de votre propre lien unique ! Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter. No comment provided by engineer. + + Content violates conditions of use + Le contenu enfreint les conditions d'utilisation + blocking reason + Continue Continuer @@ -1932,6 +2112,7 @@ Il s'agit de votre propre lien unique ! Create 1-time link + Créer un lien unique No comment provided by engineer. @@ -1964,6 +2145,11 @@ Il s'agit de votre propre lien unique ! Créer un lien No comment provided by engineer. + + Create list + Créer une liste + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Créer un nouveau profil sur [l'application de bureau](https://simplex.chat/downloads/). 💻 @@ -2021,6 +2207,7 @@ Il s'agit de votre propre lien unique ! Current conditions text couldn't be loaded, you can review conditions via this link: + Le texte sur les conditions actuelles n'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien : No comment provided by engineer. @@ -2180,8 +2367,7 @@ Il s'agit de votre propre lien unique ! Delete Supprimer alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2220,6 +2406,12 @@ Il s'agit de votre propre lien unique ! Delete chat + Supprimer la discussion + No comment provided by engineer. + + + Delete chat messages from your device. + Supprimer les messages de chat de votre appareil. No comment provided by engineer. @@ -2232,8 +2424,13 @@ Il s'agit de votre propre lien unique ! Supprimer le profil du chat ? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? + Supprimer la discussion ? No comment provided by engineer. @@ -2311,6 +2508,11 @@ Il s'agit de votre propre lien unique ! Supprimer le lien ? No comment provided by engineer. + + Delete list? + Supprimer la liste ? + alert title + Delete member message? Supprimer le message de ce membre ? @@ -2324,7 +2526,7 @@ Il s'agit de votre propre lien unique ! Delete messages Supprimer les messages - No comment provided by engineer. + alert button Delete messages after @@ -2361,6 +2563,11 @@ Il s'agit de votre propre lien unique ! Supprimer la file d'attente server test step + + Delete report + Supprimer le rapport + No comment provided by engineer. + Delete up to 20 messages at once. Supprimez jusqu'à 20 messages à la fois. @@ -2398,6 +2605,7 @@ Il s'agit de votre propre lien unique ! Delivered even when Apple drops them. + Distribués même quand Apple les oublie. No comment provided by engineer. @@ -2502,6 +2710,7 @@ Il s'agit de votre propre lien unique ! Direct messages between members are prohibited in this chat. + Les messages directs entre membres sont interdits dans cette discussion. No comment provided by engineer. @@ -2519,6 +2728,16 @@ Il s'agit de votre propre lien unique ! Désactiver SimpleX Lock authentication reason + + Disable automatic message deletion? + Désactiver la suppression automatique des messages ? + alert title + + + Disable delete messages + Désactiver la suppression des messages + alert button + Disable for all Désactiver pour tous @@ -2609,6 +2828,11 @@ Il s'agit de votre propre lien unique ! Ne pas utiliser d'identifiants avec le proxy. No comment provided by engineer. + + Documents: + Documents: + No comment provided by engineer. + Don't create address Ne pas créer d'adresse @@ -2619,9 +2843,19 @@ Il s'agit de votre propre lien unique ! Ne pas activer No comment provided by engineer. + + Don't miss important messages. + Ne manquez pas les messages importants. + No comment provided by engineer. + Don't show again Ne plus afficher + alert action + + + Done + Terminé No comment provided by engineer. @@ -2633,7 +2867,7 @@ Il s'agit de votre propre lien unique ! Download Télécharger alert button - chat item action +chat item action Download errors @@ -2687,6 +2921,7 @@ Il s'agit de votre propre lien unique ! E2E encrypted notifications. + Notifications chiffrées E2E. No comment provided by engineer. @@ -2709,8 +2944,9 @@ Il s'agit de votre propre lien unique ! Activer (conserver les remplacements) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. + Activez Flux dans les paramètres du réseau et des serveurs pour une meilleure confidentialité des métadonnées. No comment provided by engineer. @@ -2726,7 +2962,7 @@ Il s'agit de votre propre lien unique ! Enable automatic message deletion? Activer la suppression automatique des messages ? - No comment provided by engineer. + alert title Enable camera access @@ -2853,6 +3089,11 @@ Il s'agit de votre propre lien unique ! La renégociation du chiffrement a échoué. No comment provided by engineer. + + Encryption renegotiation in progress. + Renégociation du chiffrement en cours. + No comment provided by engineer. + Enter Passcode Entrer le code d'accès @@ -2920,6 +3161,7 @@ Il s'agit de votre propre lien unique ! Error accepting conditions + Erreur lors de la validation des conditions alert title @@ -2927,6 +3169,10 @@ Il s'agit de votre propre lien unique ! Erreur de validation de la demande de contact No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Erreur lors de l'ajout de membre·s @@ -2934,6 +3180,7 @@ Il s'agit de votre propre lien unique ! Error adding server + Erreur lors de l'ajout du serveur alert title @@ -2961,6 +3208,11 @@ Il s'agit de votre propre lien unique ! Erreur lors du passage en mode incognito ! No comment provided by engineer. + + Error checking token status + Erreur lors de la vérification de l'état du jeton (token) + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard. @@ -2981,6 +3233,11 @@ Il s'agit de votre propre lien unique ! Erreur lors de la création du lien du groupe No comment provided by engineer. + + Error creating list + Erreur lors de la création de la liste + alert title + Error creating member contact Erreur lors de la création du contact du membre @@ -2996,6 +3253,11 @@ Il s'agit de votre propre lien unique ! Erreur lors de la création du profil ! No comment provided by engineer. + + Error creating report + Erreur lors de la création du rapport + No comment provided by engineer. + Error decrypting file Erreur lors du déchiffrement du fichier @@ -3006,6 +3268,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la suppression de la base de données du chat No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Erreur lors de la suppression du chat ! @@ -3078,6 +3344,7 @@ Il s'agit de votre propre lien unique ! Error loading servers + Erreur de chargement des serveurs alert title @@ -3105,10 +3372,20 @@ Il s'agit de votre propre lien unique ! Erreur de reconnexion des serveurs No comment provided by engineer. + + Error registering for notifications + Erreur lors de l'inscription aux notifications + alert title + Error removing member Erreur lors de la suppression d'un membre - No comment provided by engineer. + alert title + + + Error reordering lists + Erreur lors de la réorganisation des listes + alert title Error resetting statistics @@ -3120,6 +3397,11 @@ Il s'agit de votre propre lien unique ! Erreur lors de la sauvegarde des serveurs ICE No comment provided by engineer. + + Error saving chat list + Erreur lors de l'enregistrement de la liste des chats + alert title + Error saving group profile Erreur lors de la sauvegarde du profil de groupe @@ -3137,6 +3419,7 @@ Il s'agit de votre propre lien unique ! Error saving servers + Erreur d'enregistrement des serveurs alert title @@ -3199,6 +3482,11 @@ Il s'agit de votre propre lien unique ! Erreur de synchronisation de connexion No comment provided by engineer. + + Error testing server connection + Erreur lors du test de connexion au serveur + No comment provided by engineer. + Error updating group link Erreur lors de la mise à jour du lien de groupe @@ -3211,6 +3499,7 @@ Il s'agit de votre propre lien unique ! Error updating server + Erreur de mise à jour du serveur alert title @@ -3241,7 +3530,9 @@ Il s'agit de votre propre lien unique ! Error: %@ Erreur : %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3260,6 +3551,7 @@ Il s'agit de votre propre lien unique ! Errors in servers configuration. + Erreurs dans la configuration des serveurs. servers error @@ -3277,6 +3569,11 @@ Il s'agit de votre propre lien unique ! Étendre chat item action + + Expired + Expiré + token status text + Export database Exporter la base de données @@ -3317,20 +3614,35 @@ Il s'agit de votre propre lien unique ! Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne ! No comment provided by engineer. + + Faster deletion of groups. + Suppression plus rapide des groupes. + No comment provided by engineer. + Faster joining and more reliable messages. Connexion plus rapide et messages plus fiables. No comment provided by engineer. + + Faster sending messages. + Envoi plus rapide des messages. + No comment provided by engineer. + Favorite Favoris swipe action + + Favorites + Favoris + No comment provided by engineer. + File error Erreur de fichier - No comment provided by engineer. + file error alert title File errors: @@ -3339,6 +3651,13 @@ Il s'agit de votre propre lien unique ! %@ alert message + + File is blocked by server operator: +%@. + Le fichier est bloqué par l'opérateur du serveur : +%@. + file error text + File not found - most likely file was deleted or cancelled. Fichier introuvable - le fichier a probablement été supprimé ou annulé. @@ -3464,8 +3783,13 @@ Il s'agit de votre propre lien unique ! Correction non prise en charge par un membre du groupe No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: + Pour le profil de discussion %@ : servers error @@ -3475,14 +3799,21 @@ Il s'agit de votre propre lien unique ! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. + No comment provided by engineer. + + + For me No comment provided by engineer. For private routing + Pour le routage privé No comment provided by engineer. For social media + Pour les réseaux sociaux No comment provided by engineer. @@ -3599,6 +3930,10 @@ Erreur : %2$@ GIFs et stickers No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Bonjour ! @@ -3704,11 +4039,19 @@ Erreur : %2$@ Le groupe va être supprimé pour vous - impossible de revenir en arrière ! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Aide No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Caché @@ -3761,12 +4104,18 @@ Erreur : %2$@ How it affects privacy + L'impact sur la vie privée No comment provided by engineer. How it helps privacy + Comment il contribue à la protection de la vie privée No comment provided by engineer. + + How it works + alert button + How to Comment faire @@ -3909,6 +4258,14 @@ D'autres améliorations sont à venir ! Sons d'appel No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4001,6 +4358,26 @@ D'autres améliorations sont à venir ! Couleurs d'interface No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Code QR invalide @@ -4063,6 +4440,7 @@ D'autres améliorations sont à venir ! Invite to chat + Inviter à discuter No comment provided by engineer. @@ -4225,10 +4603,12 @@ Voici votre lien pour le groupe %@ ! Leave chat + Quitter la discussion No comment provided by engineer. Leave chat? + Quitter la discussion ? No comment provided by engineer. @@ -4271,6 +4651,18 @@ Voici votre lien pour le groupe %@ ! Bureaux liés No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Message dynamique ! @@ -4356,13 +4748,22 @@ Voici votre lien pour le groupe %@ ! Membre No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Membre inactif item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. + Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés. No comment provided by engineer. @@ -4377,6 +4778,7 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! + Le membre sera retiré de la discussion - cela ne peut pas être annulé ! No comment provided by engineer. @@ -4384,6 +4786,10 @@ Voici votre lien pour le groupe %@ ! Ce membre sera retiré du groupe - impossible de revenir en arrière ! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Les membres du groupe peuvent ajouter des réactions aux messages. @@ -4394,6 +4800,10 @@ Voici votre lien pour le groupe %@ ! Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés. (24 heures) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Les membres du groupe peuvent envoyer des liens SimpleX. @@ -4419,6 +4829,10 @@ Voici votre lien pour le groupe %@ ! Les membres du groupe peuvent envoyer des messages vocaux. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menus @@ -4529,6 +4943,10 @@ Voici votre lien pour le groupe %@ ! Les messages de %@ seront affichés ! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Messages reçus @@ -4629,6 +5047,10 @@ Voici votre lien pour le groupe %@ ! Modéré à : %@ copied message info + + More + swipe action + More improvements are coming soon! Plus d'améliorations à venir ! @@ -4641,6 +5063,7 @@ Voici votre lien pour le groupe %@ ! More reliable notifications + Notifications plus fiables No comment provided by engineer. @@ -4656,7 +5079,11 @@ Voici votre lien pour le groupe %@ ! Mute Muet - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4680,6 +5107,7 @@ Voici votre lien pour le groupe %@ ! Network decentralization + Décentralisation du réseau No comment provided by engineer. @@ -4694,6 +5122,7 @@ Voici votre lien pour le groupe %@ ! Network operator + Opérateur de réseau No comment provided by engineer. @@ -4706,6 +5135,10 @@ Voici votre lien pour le groupe %@ ! État du réseau No comment provided by engineer. + + New + token status text + New Passcode Nouveau code d'accès @@ -4753,6 +5186,7 @@ Voici votre lien pour le groupe %@ ! New events + Nouveaux événements notification @@ -4770,6 +5204,10 @@ Voici votre lien pour le groupe %@ ! Nouveau rôle No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Nouveau message @@ -4782,6 +5220,7 @@ Voici votre lien pour le groupe %@ ! New server + Nouveau serveur No comment provided by engineer. @@ -4794,6 +5233,22 @@ Voici votre lien pour le groupe %@ ! Pas de mot de passe pour l'app Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Aucun contact sélectionné @@ -4841,10 +5296,16 @@ Voici votre lien pour le groupe %@ ! No media & file servers. + Pas de serveurs de médias et de fichiers. servers error + + No message + No comment provided by engineer. + No message servers. + Pas de serveurs de messages. servers error @@ -4879,20 +5340,32 @@ Voici votre lien pour le groupe %@ ! No servers for private message routing. + Pas de serveurs pour le routage privé des messages. servers error No servers to receive files. + Pas de serveurs pour recevoir des fichiers. servers error No servers to receive messages. + Pas de serveurs pour recevoir des messages. servers error No servers to send files. + Pas de serveurs pour envoyer des fichiers. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. Aucun identifiant d'utilisateur. @@ -4903,6 +5376,10 @@ Voici votre lien pour le groupe %@ ! Non compatible ! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Aucune sélection @@ -4923,10 +5400,19 @@ Voici votre lien pour le groupe %@ ! Les notifications sont désactivées ! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy + Notifications sécurisées No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4949,7 +5435,8 @@ Voici votre lien pour le groupe %@ ! Ok Ok - alert button + alert action +alert button Old database @@ -4982,6 +5469,7 @@ Nécessite l'activation d'un VPN. Only chat owners can change preferences. + Seuls les propriétaires peuvent modifier les préférences. No comment provided by engineer. @@ -5009,6 +5497,14 @@ Nécessite l'activation d'un VPN. Seuls les propriétaires de groupes peuvent activer les messages vocaux. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Vous seul pouvez ajouter des réactions aux messages. @@ -5062,7 +5558,7 @@ Nécessite l'activation d'un VPN. Open Ouvrir - No comment provided by engineer. + alert action Open Settings @@ -5071,6 +5567,7 @@ Nécessite l'activation d'un VPN. Open changes + Ouvrir les modifications No comment provided by engineer. @@ -5085,6 +5582,7 @@ Nécessite l'activation d'un VPN. Open conditions + Ouvrir les conditions No comment provided by engineer. @@ -5092,6 +5590,10 @@ Nécessite l'activation d'un VPN. Ouvrir le groupe No comment provided by engineer. + + Open link? + alert title + Open migration to another device Ouvrir le transfert vers un autre appareil @@ -5104,14 +5606,17 @@ Nécessite l'activation d'un VPN. Operator + Opérateur No comment provided by engineer. Operator server + Serveur de l'opérateur alert title Or import archive file + Ou importer un fichier d'archive No comment provided by engineer. @@ -5136,6 +5641,11 @@ Nécessite l'activation d'un VPN. Or to share privately + Ou à partager en privé + No comment provided by engineer. + + + Organize chats into lists No comment provided by engineer. @@ -5329,6 +5839,22 @@ Erreur : %@ Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Interface en polonais @@ -5356,6 +5882,7 @@ Erreur : %@ Preset servers + Serveurs prédéfinis No comment provided by engineer. @@ -5375,6 +5902,11 @@ Erreur : %@ Privacy for your customers. + Respect de la vie privée de vos clients. + No comment provided by engineer. + + + Privacy policy and conditions of use. No comment provided by engineer. @@ -5382,11 +5914,19 @@ Erreur : %@ La vie privée redéfinie No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Noms de fichiers privés No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Routage privé des messages @@ -5462,6 +6002,10 @@ Erreur : %@ Interdire les réactions aux messages. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Interdire l'envoi de liens SimpleX. @@ -5719,11 +6263,23 @@ Activez-le dans les paramètres *Réseau et serveurs*. Réduction de la consommation de batterie No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Rejeter reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5735,6 +6291,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Rejeter la demande de contact No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Le serveur relais n'est utilisé que si nécessaire. Un tiers peut observer votre adresse IP. @@ -5820,6 +6380,50 @@ Activez-le dans les paramètres *Réseau et serveurs*. Répondre chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required Requis @@ -5907,11 +6511,16 @@ Activez-le dans les paramètres *Réseau et serveurs*. Review conditions + Vérifier les conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5962,13 +6571,17 @@ Activez-le dans les paramètres *Réseau et serveurs*. Save Enregistrer alert button - chat item action +chat item action Save (and notify contacts) Enregistrer (et en informer les contacts) alert button + + Save admission settings? + alert title + Save and notify contact Enregistrer et en informer le contact @@ -5994,6 +6607,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Enregistrer le profil du groupe No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Enregistrer la phrase secrète et ouvrir le chat @@ -6234,6 +6851,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Envoi de notifications No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Envoyez vos questions et idées @@ -6361,6 +6982,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Server added to operator %@. + Serveur ajouté à l'opérateur %@. alert message @@ -6380,14 +7002,17 @@ Activez-le dans les paramètres *Réseau et serveurs*. Server operator changed. + L'opérateur du serveur a changé. alert title Server operators + Opérateurs de serveur No comment provided by engineer. Server protocol changed. + Le protocole du serveur a été modifié. alert title @@ -6445,6 +7070,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Définir 1 jour No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Définir le nom du contact… @@ -6465,6 +7094,14 @@ Activez-le dans les paramètres *Réseau et serveurs*. Il permet de remplacer l'authentification du système. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Définir le code d'accès @@ -6509,7 +7146,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share Partager alert action - chat item action +chat item action Share 1-time link @@ -6518,10 +7155,12 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share 1-time link with a friend + Partager un lien unique avec un ami No comment provided by engineer. Share SimpleX address on social media. + Partagez votre adresse SimpleX sur les réseaux sociaux. No comment provided by engineer. @@ -6531,6 +7170,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share address publicly + Partager publiquement votre adresse No comment provided by engineer. @@ -6568,6 +7208,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Partager avec vos contacts No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Afficher le code QR @@ -6625,6 +7269,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application. No comment provided by engineer. @@ -6659,12 +7304,18 @@ Activez-le dans les paramètres *Réseau et serveurs*. SimpleX address and 1-time links are safe to share via any messenger. + Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n'importe quelle messagerie. No comment provided by engineer. SimpleX address or 1-time link? + Adresse SimpleX ou lien unique ? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Adresse de contact SimpleX @@ -6758,6 +7409,8 @@ Activez-le dans les paramètres *Réseau et serveurs*. Some servers failed the test: %@ + Certains serveurs ont échoué le test : +%@ alert message @@ -6765,6 +7418,11 @@ Activez-le dans les paramètres *Réseau et serveurs*. Quelqu'un notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. Carré, circulaire, ou toute autre forme intermédiaire. @@ -6850,6 +7508,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Arrêt du chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Fort @@ -6910,6 +7572,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Délai de connexion TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6937,6 +7603,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Tap Create SimpleX address in the menu to create it later. + Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. No comment provided by engineer. @@ -6977,13 +7644,17 @@ Activez-le dans les paramètres *Réseau et serveurs*. Temporary file error Erreur de fichier temporaire - No comment provided by engineer. + file error alert title Test failed at step %@. Échec du test à l'étape %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Tester le serveur @@ -7028,6 +7699,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The app protects your privacy by using different operators in each conversation. + L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. No comment provided by engineer. @@ -7047,6 +7719,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The connection reached the limit of undelivered messages, your contact may be offline. + La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. No comment provided by engineer. @@ -7104,21 +7777,14 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. L'ancienne base de données n'a pas été supprimée lors de la migration, elle peut être supprimée. No comment provided by engineer. - - The profile is only shared with your contacts. - Le profil n'est partagé qu'avec vos contacts. - No comment provided by engineer. - The same conditions will apply to operator **%@**. - No comment provided by engineer. - - - The same conditions will apply to operator(s): **%@**. + Les mêmes conditions s'appliquent à l'opérateur **%@**. No comment provided by engineer. The second preset operator in the app! + Le deuxième opérateur prédéfini de l'application ! No comment provided by engineer. @@ -7138,6 +7804,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The servers for new files of your current chat profile **%@**. + Les serveurs pour les nouveaux fichiers de votre profil de chat actuel **%@**. No comment provided by engineer. @@ -7157,6 +7824,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. These conditions will also apply for: **%@**. + Ces conditions s'appliquent également aux : **%@**. No comment provided by engineer. @@ -7179,6 +7847,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. @@ -7224,11 +7896,19 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Voici votre propre lien unique ! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Ce lien a été utilisé avec un autre appareil mobile, veuillez créer un nouveau lien sur le bureau. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Ce paramètre s'applique aux messages de votre profil de chat actuel **%@**. @@ -7261,6 +7941,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. To protect against your link being replaced, you can compare contact security codes. + Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. No comment provided by engineer. @@ -7287,6 +7968,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To receive + Pour recevoir No comment provided by engineer. @@ -7311,6 +7993,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To send + Pour envoyer No comment provided by engineer. @@ -7320,6 +8003,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To use the servers of **%@**, accept conditions of use. + Pour utiliser les serveurs de **%@**, acceptez les conditions d'utilisation. No comment provided by engineer. @@ -7337,6 +8021,10 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Basculer en mode incognito lors de la connexion. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Opacité de la barre d'outils @@ -7414,6 +8102,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Undelivered messages + Messages non distribués No comment provided by engineer. @@ -7501,13 +8190,17 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Unmute Démute - swipe action + notification label action Unread Non lu swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Les 100 derniers messages sont envoyés aux nouveaux membres. @@ -7533,6 +8226,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Mettre à jour les paramètres ? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. La mise à jour des ces paramètres reconnectera le client à tous les serveurs. @@ -7575,6 +8272,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use %@ + Utiliser %@ No comment provided by engineer. @@ -7592,6 +8290,14 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser les serveurs SimpleX Chat ? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Utiliser le chat @@ -7604,10 +8310,12 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use for files + Utiliser pour les fichiers No comment provided by engineer. Use for messages + Utiliser pour les messages No comment provided by engineer. @@ -7652,6 +8360,11 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use servers + Utiliser les serveurs + No comment provided by engineer. + + + Use short links (BETA) No comment provided by engineer. @@ -7664,6 +8377,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser l'application d'une main. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Sélection de l'utilisateur @@ -7746,6 +8463,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien View conditions + Voir les conditions No comment provided by engineer. @@ -7755,6 +8473,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien View updated conditions + Voir les conditions mises à jour No comment provided by engineer. @@ -7869,6 +8588,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + Lorsque plusieurs opérateurs sont activés, aucun d'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. No comment provided by engineer. @@ -7968,6 +8688,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien You are already connected with %@. + Vous êtes déjà connecté avec %@. No comment provided by engineer. @@ -8032,12 +8753,9 @@ Répéter la demande d'adhésion ? Vous pouvez choisir de le modifier dans les paramètres d'apparence. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. + Vous pouvez configurer les serveurs via les paramètres. No comment provided by engineer. @@ -8082,6 +8800,7 @@ Répéter la demande d'adhésion ? You can set connection name, to remember who the link was shared with. + Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. No comment provided by engineer. @@ -8124,6 +8843,10 @@ Répéter la demande d'adhésion ? Vous pouvez à nouveau consulter le lien d'invitation dans les détails de la connexion. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Vous ne pouvez pas envoyer de messages ! @@ -8206,6 +8929,10 @@ Répéter la demande de connexion ? Vous avez envoyé une invitation de groupe No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Vous serez connecté·e au groupe lorsque l'appareil de l'hôte sera en ligne, veuillez attendre ou vérifier plus tard ! @@ -8243,6 +8970,7 @@ Répéter la demande de connexion ? You will stop receiving messages from this chat. Chat history will be preserved. + Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé. No comment provided by engineer. @@ -8270,11 +8998,6 @@ Répéter la demande de connexion ? Vos serveurs ICE No comment provided by engineer. - - Your SMP servers - Vos serveurs SMP - No comment provided by engineer. - Your SimpleX address Votre adresse SimpleX @@ -8360,6 +9083,11 @@ Répéter la demande de connexion ? Votre profil **%@** sera partagé. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Le profil n'est partagé qu'avec vos contacts. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Votre profil est stocké sur votre appareil et est seulement partagé avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil. @@ -8370,11 +9098,6 @@ Répéter la demande de connexion ? Votre profil a été modifié. Si vous l'enregistrez, le profil mis à jour sera envoyé à tous vos contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil. - No comment provided by engineer. - Your random profile Votre profil aléatoire @@ -8387,6 +9110,7 @@ Répéter la demande de connexion ? Your servers + Vos serveurs No comment provided by engineer. @@ -8424,6 +9148,10 @@ Répéter la demande de connexion ? ci-dessus, puis choisissez : No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call appel accepté @@ -8431,8 +9159,13 @@ Répéter la demande de connexion ? accepted invitation + invitation acceptée chat list item title + + accepted you + rcv group event chat item + admin admin @@ -8453,6 +9186,10 @@ Répéter la demande de connexion ? négociation du chiffrement… chat item text + + all + member criteria value + all members tous les membres @@ -8468,6 +9205,10 @@ Répéter la demande de connexion ? et %lld autres événements No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts tentatives @@ -8506,7 +9247,8 @@ Répéter la demande de connexion ? blocked by admin bloqué par l'administrateur - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8533,6 +9275,10 @@ Répéter la demande de connexion ? appel… call status + + can't send messages + No comment provided by engineer. + cancelled %@ annulé %@ @@ -8638,6 +9384,14 @@ Répéter la demande de connexion ? le contact %1$@ est devenu %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption Ce contact a le chiffrement de bout en bout @@ -8648,6 +9402,10 @@ Répéter la demande de connexion ? Ce contact n'a pas le chiffrement de bout en bout No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator créateur @@ -8676,7 +9434,8 @@ Répéter la demande de connexion ? default (%@) défaut (%@) - pref value + delete after time +pref value default (no) @@ -8803,20 +9562,11 @@ Répéter la demande de connexion ? erreur No comment provided by engineer. - - event happened - event happened - No comment provided by engineer. - expired expiré No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded transféré @@ -8827,6 +9577,10 @@ Répéter la demande de connexion ? groupe supprimé No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated mise à jour du profil de groupe @@ -8952,6 +9706,10 @@ Répéter la demande de connexion ? est connecté·e rcv group event chat item + + member has old version + No comment provided by engineer. + message message @@ -8982,20 +9740,19 @@ Répéter la demande de connexion ? modéré par %@ marked deleted chat item preview text + + moderator + member role + months mois time unit - - mute - muet - No comment provided by engineer. - never jamais - No comment provided by engineer. + delete after time new message @@ -9017,6 +9774,10 @@ Répéter la demande de connexion ? aucun texte copied message info in history + + not synchronized + No comment provided by engineer. + observer observateur @@ -9026,8 +9787,9 @@ Répéter la demande de connexion ? off off enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9069,6 +9831,18 @@ Répéter la demande de connexion ? pair-à-pair No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chiffrement e2e résistant post-quantique @@ -9084,6 +9858,10 @@ Répéter la demande de connexion ? confimation reçu… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call appel rejeté @@ -9104,6 +9882,10 @@ Répéter la demande de connexion ? suppression de l'adresse de contact profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture suppression de la photo de profil @@ -9114,10 +9896,23 @@ Répéter la demande de connexion ? vous a retiré rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect + demande à se connecter chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved enregistré @@ -9217,11 +10012,6 @@ dernier message reçu : %2$@ statut inconnu No comment provided by engineer. - - unmute - démuter - No comment provided by engineer. - unprotected non protégé @@ -9312,6 +10102,10 @@ dernier message reçu : %2$@ vous No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group vous êtes invité·e au groupe @@ -9386,7 +10180,7 @@ dernier message reçu : %2$@
- +
@@ -9423,7 +10217,7 @@ dernier message reçu : %2$@
- +
@@ -9445,34 +10239,38 @@ dernier message reçu : %2$@
- +
%d new events + %d nouveaux événements + notification body + + + From %d chat(s) notification body From: %@ + De : %@ notification body New events + Nouveaux événements notification New messages + Nouveaux messages notification - - New messages in %d chats - notification body -
- +
@@ -9494,7 +10292,7 @@ dernier message reçu : %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/contents.json b/apps/ios/SimpleX Localizations/fr.xcloc/contents.json index 22d271b92e..d026c874ec 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/fr.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "fr", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff index 08f46bb056..f76d7eba1e 100644 --- a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff +++ b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff @@ -3569,8 +3569,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index 2fd96e3492..6ad4d159c7 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -114,12 +114,12 @@ %lld - + No comment provided by engineer. %lld %@ - + No comment provided by engineer. @@ -144,12 +144,12 @@ %lldd - + No comment provided by engineer. %lldh - + No comment provided by engineer. @@ -158,7 +158,7 @@ %lldm - + No comment provided by engineer. @@ -173,12 +173,14 @@ %lldw No comment provided by engineer. - + ( + ( No comment provided by engineer. - + ) + ) No comment provided by engineer. @@ -248,22 +250,22 @@ 1 day - 1 dan + 1 dan message ttl 1 hour - 1 sat + 1 sat message ttl 1 month - 1 mjesec + 1 mesec message ttl 1 week - 1 tjedan + 1 nedelja message ttl @@ -1518,16 +1520,19 @@ Immune to spam No comment provided by engineer. - + Import + Uvesti No comment provided by engineer. - + Import chat database? + Uvesti data bazu razgovora? No comment provided by engineer. - + Import database + Uvesti data bazu No comment provided by engineer. @@ -1688,12 +1693,14 @@ We will be adding server redundancy to prevent lost messages. Live message! No comment provided by engineer. - + Live messages + Žive poruke No comment provided by engineer. - + Local name + Lokalno ime No comment provided by engineer. @@ -2612,8 +2619,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. @@ -3152,8 +3159,9 @@ SimpleX servers cannot see your profile. \_italic_ No comment provided by engineer. - + \`a + b` + \`a + b` No comment provided by engineer. @@ -3613,6 +3621,110 @@ SimpleX servers cannot see your profile. \~strike~ No comment provided by engineer. + + # %@ + # %@ + + + %@ server + %@ server + + + %@ servers + %@ serveri + + + Import failed + Uvoz neuspešan + + + %@ downloaded + %@ preuzeto + + + %@ uploaded + %@ otpremljeno + + + 1 minute + 1 minut + + + Password + Šifra + + + ## History + ## Istorija + + + %@ (current) + %@ (trenutan) + + + %@ and %@ + %@ i %@ + + + %@ connected + %@ povezan + + + 0 sec + 0 sek + + + 5 minutes + 5 minuta + + + %@ (current): + %@ (trenutan): + + + %@ and %@ connected + %@ i %@ su povezani + + + %@: + %@: + + + %1$@ at %2$@: + %1$@ u %2$@: + + + 30 seconds + 30 sekundi + + + Password to show + Prikazati šifru + + + %1$@, %2$@ + %1$@, %2$@ + + + 0s + 0s + + + Import theme + Uvesti temu + + + Immediately + Odmah + + + Address settings + Podešavanje adrese + + + Admins can block a member for all. + Administratori mogu da blokiraju +
diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 231c33523d..5fd4c21027 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (másolható) @@ -39,7 +27,7 @@ ## In reply to - ## Válaszul erre: + ## Válaszul erre copied message info @@ -104,7 +92,7 @@ %@ is not verified - %@ nem hitelesített + %@ nincs hitelesítve No comment provided by engineer. @@ -164,7 +152,7 @@ %d file(s) failed to download. - %d fájlt nem sikerült letölteni. + Nem sikerült letölteni %d fájlt. forward confirmation reason @@ -202,6 +190,11 @@ %d mp time interval + + %d seconds(s) + %d másodperc + delete after time + %d skipped message(s) %d üzenet kihagyva @@ -224,12 +217,12 @@ %lld contact(s) selected - %lld ismerős kiválasztva + %lld partner kijelölve No comment provided by engineer. %lld file(s) with total size of %@ - %lld fájl, amely(ek)nek teljes mérete: %@ + %lld fájl, %@ összméretben No comment provided by engineer. @@ -254,7 +247,7 @@ %lld messages marked deleted - %lld törlésre megjelölt üzenet + %lld üzenet megjelölve törlésre No comment provided by engineer. @@ -272,24 +265,19 @@ %lld új kezelőfelületi nyelv No comment provided by engineer. - - %lld second(s) - %lld másodperc - No comment provided by engineer. - %lld seconds - %lld másodperc + %lld mp No comment provided by engineer. %lldd - %lldd + %lldn No comment provided by engineer. %lldh - %lldh + %lldó No comment provided by engineer. @@ -299,27 +287,27 @@ %lldm - %lldm + %lldp No comment provided by engineer. %lldmth - %lldmth + %lldh No comment provided by engineer. %llds - %llds + %lldmp No comment provided by engineer. %lldw - %lldw + %lldhét No comment provided by engineer. %u messages failed to decrypt. - %u üzenet visszafejtése sikertelen. + Nem sikerült visszafejteni %u üzenetet. No comment provided by engineer. @@ -327,11 +315,6 @@ %u üzenet kihagyva. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (új) @@ -342,14 +325,9 @@ (ez az eszköz: v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. - **Ismerős hozzáadása:** új meghívó-hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. + **Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. No comment provided by engineer. @@ -359,7 +337,7 @@ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. - **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken megosztásra kerül a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van. + **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van. No comment provided by engineer. @@ -369,17 +347,17 @@ **Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection. - **Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését. + **Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését. No comment provided by engineer. **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Megjegyzés:** NEM tudja visszaállítani vagy megváltoztatni jelmondatát, ha elveszíti azt. + **Megjegyzés:** NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti. No comment provided by engineer. **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. - **Megjegyzés:** az eszköztoken és az értesítések elküldésre kerülnek a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. + **Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. No comment provided by engineer. @@ -394,7 +372,7 @@ **Warning**: the archive will be removed. - **Figyelmeztetés:** az archívum eltávolításra kerül. + **Figyelmeztetés:** az archívum el lesz távolítva. No comment provided by engineer. @@ -412,11 +390,6 @@ \*félkövér* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -431,7 +404,7 @@ - a bit better groups. - and more! - stabilabb üzenetkézbesítés. -- valamivel jobb csoportok. +- picit továbbfejlesztett csoportok. - és még sok más! No comment provided by engineer. @@ -439,8 +412,8 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! - - értesíti az ismerősöket a törlésről (nem kötelező) -- profil nevek szóközökkel + - partnerek értesítése a törlésről (nem kötelező) +- profilnevek szóközökkel - és még sok más! No comment provided by engineer.
@@ -448,16 +421,11 @@ - voice messages up to 5 minutes. - custom time to disappear. - editing history. - - 5 perc hosszúságú hangüzenetek. -- egyedi üzenet-eltűnési időkorlát. + - legfeljebb 5 perc hosszúságú hangüzenetek. +- egyéni üzenet-eltűnési időkorlát. - előzmények szerkesztése. No comment provided by engineer.
- - . - . - No comment provided by engineer. - 0 sec 0 mp @@ -471,7 +439,8 @@ 1 day 1 nap - time interval + delete after time +time interval 1 hour @@ -486,21 +455,28 @@ 1 month 1 hónap - time interval + delete after time +time interval 1 week 1 hét - time interval + delete after time +time interval + + + 1 year + 1 év + delete after time 1-time link - Egyszer használható meghívó-hivatkozás + Egyszer használható meghívó No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. - Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* - személyesen vagy bármilyen üzenetküldőn keresztül megosztható. + Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható. No comment provided by engineer. @@ -518,11 +494,6 @@ 30 másodperc No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -532,17 +503,17 @@ A few more things - Még néhány dolog + Néhány további dolog No comment provided by engineer. A new contact - Egy új ismerős + Egy új partner notification title A new random profile will be shared. - Egy új, véletlenszerű profil kerül megosztásra. + Egy új, véletlenszerű profil lesz megosztva. No comment provided by engineer. @@ -553,7 +524,7 @@ A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. - **Az összes ismerőséhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva. + **Az összes partneréhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva. **Megjegyzés:** ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet. No comment provided by engineer. @@ -564,17 +535,17 @@ Abort changing address - Címváltoztatás megszakítása + Cím módosításának megszakítása No comment provided by engineer. Abort changing address? - Címváltoztatás megszakítása?? + Megszakítja a cím módosítását? No comment provided by engineer. About SimpleX Chat - SimpleX Chat névjegye + A SimpleX Chat névjegye No comment provided by engineer. @@ -584,15 +555,25 @@ Accent - Kiemelés + Kiemelőszín No comment provided by engineer. Accept Elfogadás accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + Befogadás tagként + alert action + + + Accept as observer + Befogadás megfigyelőként + alert action Accept conditions @@ -601,19 +582,24 @@ Accept connection request? - Kapcsolatkérés elfogadása? + Elfogadja a meghívási kérést? No comment provided by engineer. Accept contact request from %@? - Elfogadja %@ kapcsolatkérését? + Elfogadja %@ meghívási kérését? notification body Accept incognito - Fogadás inkognitóban + Elfogadás inkognitóban accept contact request via notification - swipe action +swipe action + + + Accept member + Tag befogadása + alert title Accepted conditions @@ -622,14 +608,19 @@ Acknowledged - Nyugtázva + Visszaigazolt No comment provided by engineer. Acknowledgement errors - Nyugtázott hibák + Visszaigazolási hibák No comment provided by engineer. + + Active + Aktív + token status text + Active connections Aktív kapcsolatok száma @@ -637,7 +628,7 @@ Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés elküldésre kerül az ismerősei számára. + Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára. No comment provided by engineer. @@ -645,6 +636,11 @@ Barátok hozzáadása No comment provided by engineer. + + Add list + Lista hozzáadása + No comment provided by engineer. + Add profile Profil hozzáadása @@ -662,7 +658,7 @@ Add team members - Csapattagok hozzáadása + Munkatársak hozzáadása No comment provided by engineer. @@ -670,6 +666,11 @@ Hozzáadás egy másik eszközhöz No comment provided by engineer. + + Add to list + Hozzáadás listához + No comment provided by engineer. + Add welcome message Üdvözlőüzenet hozzáadása @@ -677,7 +678,7 @@ Add your team members to the conversations. - Adja hozzá csapattagjait a beszélgetésekhez. + Adja hozzá a munkatársait a beszélgetésekhez. No comment provided by engineer. @@ -692,17 +693,17 @@ Additional accent - További kiemelés + További kiemelőszín No comment provided by engineer. Additional accent 2 - További kiemelés 2 + További kiemelőszín 2 No comment provided by engineer. Additional secondary - További másodlagos + További másodlagos szín No comment provided by engineer. @@ -712,12 +713,12 @@ Address change will be aborted. Old receiving address will be used. - A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra. + A cím módosítása meg fog szakadni. A régi fogadási cím lesz használva. No comment provided by engineer. Address or 1-time link? - Cím vagy egyszer használható meghívó-hivatkozás? + Cím vagy egyszer használható meghívó? No comment provided by engineer. @@ -732,7 +733,7 @@ Admins can create the links to join groups. - Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való kapcsolódáshoz. + Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. No comment provided by engineer. @@ -745,6 +746,11 @@ Speciális beállítások No comment provided by engineer. + + All + Összes + No comment provided by engineer. + All app data is deleted. Az összes alkalmazásadat törölve. @@ -752,17 +758,22 @@ All chats and messages will be deleted - this cannot be undone! - Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Az összes csevegés el lesz távolítva a(z) %@ nevű listáról, és a lista is törölve lesz. + alert message + All data is erased when it is entered. - A jelkód megadása után az összes adat törlésre kerül. + A jelkód megadása után az összes adat törölve lesz. No comment provided by engineer. All data is kept private on your device. - Az összes adat biztonságban van az eszközén. + Az összes adat privát módon van tárolva az eszközén. No comment provided by engineer. @@ -772,22 +783,22 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. - Az összes üzenetet és fájlt **végpontok közötti titkosítással** küldi, a közvetlen üzenetekben pedig kvantumrezisztens biztonsággal. + Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek. No comment provided by engineer. All messages will be deleted - this cannot be undone! - Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza! + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. - Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. No comment provided by engineer. All new messages from %@ will be hidden! - Az összes új üzenet elrejtésre kerül tőle: %@! + %@ összes új üzenete el lesz rejtve! No comment provided by engineer. @@ -795,19 +806,29 @@ Összes profil profile dropdown + + All reports will be archived for you. + Az összes jelentés archiválva lesz az Ön számára. + No comment provided by engineer. + + + All servers + Összes kiszolgáló + No comment provided by engineer. + All your contacts will remain connected. - Az összes ismerősével kapcsolatban marad. + Az összes partnerével kapcsolatban marad. No comment provided by engineer. All your contacts will remain connected. Profile update will be sent to your contacts. - Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél. + A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. No comment provided by engineer. All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra. + Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra. No comment provided by engineer. @@ -817,17 +838,17 @@ Allow calls only if your contact allows them. - A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. No comment provided by engineer. Allow calls? - Hívások engedélyezése? + Engedélyezi a hívásokat? No comment provided by engineer. Allow disappearing messages only if your contact allows it to you. - Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi az Ön számára. + Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi az Ön számára. No comment provided by engineer. @@ -837,17 +858,17 @@ Allow irreversible message deletion only if your contact allows it to you. (24 hours) - Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra) + Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) No comment provided by engineer. Allow message reactions only if your contact allows them. - Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. No comment provided by engineer. Allow message reactions. - Üzenetreakciók engedélyezése. + A reakciók hozzáadása az üzenetekhez engedélyezve van. No comment provided by engineer. @@ -867,7 +888,12 @@ Allow to irreversibly delete sent messages. (24 hours) - Elküldött üzenetek végleges törlésének engedélyezése. (24 óra) + Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) + No comment provided by engineer. + + + Allow to report messsages to moderators. + Az üzenetek jelentése a moderátorok felé engedélyezve van. No comment provided by engineer. @@ -877,47 +903,47 @@ Allow to send files and media. - Fájlok és médiatartalmak küldésének engedélyezése. + A fájlok- és a médiatartalmak küldése engedélyezve van. No comment provided by engineer. Allow to send voice messages. - Hangüzenetek küldésének engedélyezése. + A hangüzenetek küldése engedélyezve van. No comment provided by engineer. Allow voice messages only if your contact allows them. - A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. No comment provided by engineer. Allow voice messages? - Hangüzenetek engedélyezése? + Engedélyezi a hangüzeneteket? No comment provided by engineer. Allow your contacts adding message reactions. - Az üzenetreakciók küldése engedélyezve van az ismerősei számára. + A reakciók hozzáadása az üzenetekhez engedélyezve van a partnerei számára. No comment provided by engineer. Allow your contacts to call you. - A hívások kezdeményezése engedélyezve van az ismerősei számára. + A hívások kezdeményezése engedélyezve van a partnerei számára. No comment provided by engineer. Allow your contacts to irreversibly delete sent messages. (24 hours) - Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra) + Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra) No comment provided by engineer. Allow your contacts to send disappearing messages. - Az eltűnő üzenetek küldésének engedélyezése az ismerősei számára. + Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. No comment provided by engineer. Allow your contacts to send voice messages. - A hangüzenetek küldése engedélyezve van az ismerősei számára. + A hangüzenetek küldése engedélyezve van a partnerei számára. No comment provided by engineer. @@ -942,14 +968,19 @@ Always use relay - Mindig használjon közvetítő-kiszolgálót + Mindig használjon továbbítókiszolgálót No comment provided by engineer. An empty chat profile with the provided name is created, and the app opens as usual. - Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik. + Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik. No comment provided by engineer. + + Another reason + Egyéb indoklás + report reason + Answer call Hívás fogadása @@ -962,7 +993,7 @@ App build: %@ - Az alkalmazás build száma: %@ + Az alkalmazás összeállítási száma: %@ No comment provided by engineer. @@ -975,6 +1006,11 @@ Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). No comment provided by engineer. + + App group: + Alkalmazáscsoport: + No comment provided by engineer. + App icon Alkalmazásikon @@ -987,7 +1023,7 @@ App passcode is replaced with self-destruct passcode. - Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal. + Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. No comment provided by engineer. @@ -997,12 +1033,12 @@ App version - Alkalmazás verzió + Az alkalmazás verziója No comment provided by engineer. App version: v%@ - Alkalmazás verzió: v%@ + Az alkalmazás verziója: v%@ No comment provided by engineer. @@ -1020,6 +1056,21 @@ Alkalmazás erre No comment provided by engineer. + + Archive + Archívum + No comment provided by engineer. + + + Archive %lld reports? + Archivál %lld jelentést? + No comment provided by engineer. + + + Archive all reports? + Archiválja az összes jelentést? + No comment provided by engineer. + Archive and upload Archiválás és feltöltés @@ -1027,12 +1078,27 @@ Archive contacts to chat later. - Az ismerősök archiválása a későbbi csevegéshez. + A partnerek archiválása a későbbi csevegéshez. No comment provided by engineer. + + Archive report + Jelentés archiválása + No comment provided by engineer. + + + Archive report? + Archiválja a jelentést? + No comment provided by engineer. + + + Archive reports + Jelentések archiválása + swipe action + Archived contacts - Archivált ismerősök + Archivált partnerek No comment provided by engineer. @@ -1042,7 +1108,7 @@ Attach - Csatolás + Mellékelés No comment provided by engineer. @@ -1057,7 +1123,7 @@ Audio/video calls - Hang-/videóhívások + Hang- és videóhívások chat feature @@ -1092,7 +1158,7 @@ Auto-accept contact requests - Kapcsolatkérések automatikus elfogadása + Meghívási kérések automatikus elfogadása No comment provided by engineer. @@ -1117,7 +1183,7 @@ Bad desktop address - Hibás számítógép cím + Érvénytelen számítógépcím No comment provided by engineer. @@ -1127,7 +1193,7 @@ Bad message hash - Hibás az üzenet hasító értéke + Érvénytelen az üzenet hasítóértéke No comment provided by engineer. @@ -1137,7 +1203,12 @@ Better groups - Javított csoportok + Továbbfejlesztett csoportok + No comment provided by engineer. + + + Better groups performance + Továbbfejlesztett, gyorsabb csoportok No comment provided by engineer. @@ -1147,7 +1218,7 @@ Better messages - Jobb üzenetek + Továbbfejlesztett üzenetek No comment provided by engineer. @@ -1160,6 +1231,11 @@ Továbbfejlesztett értesítések No comment provided by engineer. + + Better privacy and security + Továbbfejlesztett adatvédelem és biztonság + No comment provided by engineer. + Better security ✅ Továbbfejlesztett biztonság ✅ @@ -1182,7 +1258,7 @@ Block for all - Letiltás az összes tag számára + Letiltás No comment provided by engineer. @@ -1192,22 +1268,22 @@ Block member - Tag letiltása + Letiltás No comment provided by engineer. Block member for all? - Az összes tag számára letiltja ezt a tagot? + Az összes tag számára letiltja a tagot? No comment provided by engineer. Block member? - Tag letiltása? + Letiltja a tagot? No comment provided by engineer. Blocked by admin - Az adminisztrátor letiltotta + Letiltva az adminisztrátor által No comment provided by engineer. @@ -1222,12 +1298,12 @@ Both you and your contact can add message reactions. - Mindkét fél is hozzáadhat üzenetreakciókat. + Mindkét fél hozzáadhat az üzenetekhez reakciókat. No comment provided by engineer. Both you and your contact can irreversibly delete sent messages. (24 hours) - Mindkét fél törölheti véglegesen az elküldött üzeneteket. (24 óra) + Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) No comment provided by engineer. @@ -1260,11 +1336,25 @@ Üzleti csevegések No comment provided by engineer. + + Businesses + Üzleti + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + A SimpleX Chat használatával Ön elfogadja, hogy: +- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban. +- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek. + No comment provided by engineer. + Call already ended! A hívás már befejeződött! @@ -1287,7 +1377,7 @@ Can't call contact - Nem lehet felhívni az ismerőst + Nem lehet felhívni a partnert No comment provided by engineer. @@ -1297,12 +1387,12 @@ Can't invite contact! - Nem lehet meghívni az ismerőst! + Nem lehet meghívni a partnert! No comment provided by engineer. Can't invite contacts! - Nem lehet meghívni az ismerősöket! + Nem lehet meghívni a partnereket! No comment provided by engineer. @@ -1314,7 +1404,7 @@ Cancel Mégse alert action - alert button +alert button Cancel migration @@ -1338,7 +1428,7 @@ Capacity exceeded - recipient did not receive previously sent messages. - Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket. + Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. snd error text @@ -1348,59 +1438,64 @@ Change - Változtatás + Módosítás No comment provided by engineer. + + Change automatic message deletion? + Módosítja az automatikus üzenettörlést? + alert title + Change chat profiles - Csevegési profilok megváltoztatása + Csevegési profilok módosítása authentication reason Change database passphrase? - Adatbázis-jelmondat megváltoztatása? + Módosítja az adatbázis jelmondatát? No comment provided by engineer. Change lock mode - Zárolási mód megváltoztatása + Zárolási mód módosítása authentication reason Change member role? - Tag szerepkörének megváltoztatása? + Módosítja a tag szerepkörét? No comment provided by engineer. Change passcode - Jelkód megváltoztatása + Jelkód módosítása authentication reason Change receiving address - A fogadó cím megváltoztatása + Fogadási cím módosítása No comment provided by engineer. Change receiving address? - Megváltoztatja a fogadó címet? + Módosítja a fogadási címet? No comment provided by engineer. Change role - Szerepkör megváltoztatása + Szerepkör módosítása No comment provided by engineer. Change self-destruct mode - Önmegsemmisítő mód megváltoztatása + Önmegsemmisítő-mód módosítása authentication reason Change self-destruct passcode - Önmegsemmisító jelkód megváltoztatása + Önmegsemmisítő-jelkód módosítása authentication reason - set passcode view +set passcode view Chat @@ -1454,17 +1549,17 @@ Chat is stopped - A csevegés leállt + A csevegés megállt No comment provided by engineer. Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. - A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt. + A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. No comment provided by engineer. Chat list - Csevegőlista + Csevegési lista No comment provided by engineer. @@ -1479,7 +1574,7 @@ Chat preferences were changed. - A csevegési beállítások megváltoztak. + A csevegési beállítások módosultak. alert message @@ -1494,12 +1589,22 @@ Chat will be deleted for all members - this cannot be undone! - A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni! + A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni! + A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! + No comment provided by engineer. + + + Chat with admins + Csevegés az adminisztrátorokkal + chat toolbar + + + Chat with member + Csevegés a taggal No comment provided by engineer. @@ -1507,6 +1612,11 @@ Csevegések No comment provided by engineer. + + Chats with members + Csevegés a tagokkal + No comment provided by engineer. + Check messages every 20 min. Üzenetek ellenőrzése 20 percenként. @@ -1544,17 +1654,17 @@ Chunks deleted - Törölt fájltöredékek + Törölt töredékek No comment provided by engineer. Chunks downloaded - Letöltött fájltöredékek + Letöltött töredékek No comment provided by engineer. Chunks uploaded - Feltöltött fájltöredékek + Feltöltött töredékek No comment provided by engineer. @@ -1569,12 +1679,22 @@ Clear conversation? - Üzenetek kiürítése? + Kiüríti az üzeneteket? + No comment provided by engineer. + + + Clear group? + Kiüríti a csoportot? + No comment provided by engineer. + + + Clear or delete group? + Csoport kiürítése vagy törlése? No comment provided by engineer. Clear private notes? - Privát jegyzetek kiürítése? + Kiüríti a privát jegyzeteket? No comment provided by engineer. @@ -1592,14 +1712,19 @@ Színmód No comment provided by engineer. + + Community guidelines violation + Közösségi irányelvek megsértése + report reason + Compare file - Fájl összehasonlítás + Fájl-összehasonlítás server test step Compare security codes with your contacts. - Biztonsági kódok összehasonlítása az ismerősökével. + Biztonsági kódok összehasonlítása a partnerekével. No comment provided by engineer. @@ -1609,7 +1734,7 @@ Conditions accepted on: %@. - Feltételek elfogadva ekkor: %@. + Feltételek elfogadásának ideje: %@. No comment provided by engineer. @@ -1627,16 +1752,6 @@ Használati feltételek No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltető számára. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**. @@ -1644,12 +1759,12 @@ Conditions will be accepted on: %@. - A feltételek ekkor lesznek elfogadva: %@. + A feltételek el lesznek fogadva a következő időpontban: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. - A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltető számára: %@. + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára a következő időpontban: %@. No comment provided by engineer. @@ -1657,6 +1772,11 @@ ICE-kiszolgálók beállítása No comment provided by engineer. + + Configure server operators + Kiszolgálóüzemeltetők beállítása + No comment provided by engineer. + Confirm Megerősítés @@ -1669,7 +1789,7 @@ Confirm contact deletion? - Biztosan törli az ismerőst? + Biztosan törli a partnert? No comment provided by engineer. @@ -1707,6 +1827,11 @@ Feltöltés megerősítése No comment provided by engineer. + + Confirmed + Megerősítve + token status text + Connect Kapcsolódás @@ -1729,26 +1854,26 @@ Connect to your friends faster. - Kapcsolódjon gyorsabban az ismerőseihez. + Kapcsolódjon gyorsabban a partnereihez. No comment provided by engineer. Connect to yourself? - Kapcsolódás saját magához? + Kapcsolódik saját magához? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! - Kapcsolódás saját magához? -Ez az Ön SimpleX-címe! + Kapcsolódik saját magához? +Ez a saját SimpleX-címe! No comment provided by engineer. Connect to yourself? This is your own one-time link! - Kapcsolódás saját magához? -Ez az Ön egyszer használható meghívó-hivatkozása! + Kapcsolódik saját magához? +Ez a saját egyszer használható meghívója! No comment provided by engineer. @@ -1763,7 +1888,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Connect via one-time link - Kapcsolódás egyszer használható meghívó-hivatkozáson keresztül + Kapcsolódás egyszer használható meghívón keresztül No comment provided by engineer. @@ -1808,7 +1933,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Connecting to contact, please wait or check later! - Kapcsolódás az ismerőshöz, várjon vagy ellenőrizze később! + Kapcsolódás a partnerhez, várjon vagy ellenőrizze később! No comment provided by engineer. @@ -1826,6 +1951,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolatok- és kiszolgálók állapotának megjelenítése. No comment provided by engineer. + + Connection blocked + A kapcsolat le van tiltva + No comment provided by engineer. + Connection error Kapcsolódási hiba @@ -1836,6 +1966,18 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolódási hiba (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + A kiszolgáló üzemeltetője letiltotta a kapcsolatot: +%@ + No comment provided by engineer. + + + Connection not ready. + A kapcsolat nem áll készen. + No comment provided by engineer. + Connection notifications Kapcsolódási értesítések @@ -1843,7 +1985,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Connection request sent! - Kapcsolatkérés elküldve! + Meghívási kérés elküldve! + No comment provided by engineer. + + + Connection requires encryption renegotiation. + A kapcsolat titkosítása újraegyeztetést igényel. No comment provided by engineer. @@ -1873,32 +2020,32 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Contact allows - Ismerős engedélyezi + Partner engedélyezi No comment provided by engineer. Contact already exists - Az ismerős már létezik + A partner már létezik No comment provided by engineer. Contact deleted! - Ismerős törölve! + Partner törölve! No comment provided by engineer. Contact hidden: - Ismerős elrejtve: + Rejtett név: notification Contact is connected - Ismerőse kapcsolódott + Partnere kapcsolódott notification Contact is deleted. - Törölt ismerős. + Törölt partner. No comment provided by engineer. @@ -1908,24 +2055,29 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Contact preferences - Ismerős beállításai + Partnerbeállítások No comment provided by engineer. Contact will be deleted - this cannot be undone! - Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza! + A partner törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. Contacts - Ismerősök + Partnerek No comment provided by engineer. Contacts can mark messages for deletion; you will be able to view them. - Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. + A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. No comment provided by engineer. + + Content violates conditions of use + A tartalom sérti a használati feltételeket + blocking reason + Continue Folytatás @@ -1948,7 +2100,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Core version: v%@ - Alapverziószám: v%@ + Fő verzió: v%@ No comment provided by engineer. @@ -1958,7 +2110,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Correct name to %@? - Név javítása erre: %@? + Helyesbíti a nevet a következőre: %@? No comment provided by engineer. @@ -1968,7 +2120,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Create 1-time link - Egyszer használható meghívó-hivatkozás létrehozása + Egyszer használható meghívó létrehozása No comment provided by engineer. @@ -1978,7 +2130,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Create a group using a random profile. - Csoport létrehozása véletlenszerűen létrehozott profillal. + Csoport létrehozása véletlenszerű profillal. No comment provided by engineer. @@ -2001,6 +2153,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hivatkozás létrehozása No comment provided by engineer. + + Create list + Lista létrehozása + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻 @@ -2013,7 +2170,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Create queue - Sorbaállítás létrehozása + Sorba állítás létrehozása server test step @@ -2033,12 +2190,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Created at - Létrehozva ekkor: + Létrehozva No comment provided by engineer. Created at: %@ - Létrehozva ekkor: %@ + Létrehozva: %@ copied message info @@ -2058,7 +2215,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Current conditions text couldn't be loaded, you can review conditions via this link: - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: No comment provided by engineer. @@ -2073,17 +2230,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Currently maximum supported file size is %@. - Jelenleg a maximális támogatott fájlméret %@. + Jelenleg támogatott legnagyobb fájl méret: %@. No comment provided by engineer. Custom time - Személyreszabott idő + Egyéni időköz No comment provided by engineer. Customizable message shape. - Testreszabható üzenetbuborékok. + Személyre szabható üzenetbuborékok. No comment provided by engineer. @@ -2113,7 +2270,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database IDs and Transport isolation option. - Adatbázis-azonosítók és átvitel-izolációs beállítások. + Adatbázis-azonosítók és átvitelelkülönítési beállítások. No comment provided by engineer. @@ -2129,14 +2286,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database encryption passphrase will be updated and stored in the keychain. - Az adatbázis titkosítási jelmondata frissítve lesz és a kulcstartóban kerül tárolásra. + Az adatbázis titkosítási jelmondata frissülni fog és a kulcstartóban lesz tárolva. No comment provided by engineer. Database encryption passphrase will be updated. - Az datbázis titkosítási jelmondata frissítve lesz. + Az adatbázis titkosítási jelmondata frissítve lesz. No comment provided by engineer. @@ -2147,12 +2304,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database is encrypted using a random passphrase, you can change it. - Az adatbázis egy véletlenszerű jelmondattal van titkosítva, ami megváltoztatható. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet szabadon módosíthat. No comment provided by engineer. Database is encrypted using a random passphrase. Please change it before exporting. - Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt módosítsa. No comment provided by engineer. @@ -2167,7 +2324,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata eltér a kulcstartóban mentettől. + Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. No comment provided by engineer. @@ -2183,20 +2340,20 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database will be encrypted and the passphrase stored in the keychain. - Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban kerül tárolásra. + Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban lesz tárolva. No comment provided by engineer. Database will be encrypted. - Az adatbázis titkosításra kerül. + Az adatbázis titkosítva lesz. No comment provided by engineer. Database will be migrated when the app restarts - Az adatbázis az alkalmazás újraindításakor átköltöztetésre kerül + Az adatbázis az alkalmazás újraindításakor lesz átköltöztetve No comment provided by engineer. @@ -2211,19 +2368,18 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Decryption error - Titkosítás visszafejtési hiba + Titkosításvisszafejtési hiba message decrypt error item Delete Törlés alert action - chat item action - swipe action +swipe action Delete %lld messages of members? - Tagok %lld üzenetének törlése? + Törli a tagok %lld üzenetét? No comment provided by engineer. @@ -2238,7 +2394,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete address? - Cím törlése? + Törli a címet? No comment provided by engineer. @@ -2253,7 +2409,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete and notify contact - Törlés, és az ismerős értesítése + Törlés, és a partner értesítése No comment provided by engineer. @@ -2261,6 +2417,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Csevegés törlése No comment provided by engineer. + + Delete chat messages from your device. + Csevegési üzenetek törlése a saját eszközéről. + No comment provided by engineer. + Delete chat profile Csevegési profil törlése @@ -2268,12 +2429,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete chat profile? - Csevegési profil törlése? + Törli a csevegési profilt? No comment provided by engineer. + + Delete chat with member? + Törli a taggal való csevegést? + alert title + Delete chat? - Csevegés törlése? + Törli a csevegést? No comment provided by engineer. @@ -2283,12 +2449,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete contact - Ismerős törlése + Partner törlése No comment provided by engineer. Delete contact? - Ismerős törlése? + Törli a partnert? No comment provided by engineer. @@ -2308,7 +2474,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete files and media? - Fájlok és a médiatartalmak törlése? + Törli a fájl- és a médiatartalmakat? No comment provided by engineer. @@ -2333,7 +2499,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete group? - Csoport törlése? + Törli a csoportot? No comment provided by engineer. @@ -2343,28 +2509,33 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete link - Hivatkozás törlése + Törlés No comment provided by engineer. Delete link? - Hivatkozás törlése? + Törli a hivatkozást? No comment provided by engineer. + + Delete list? + Törli a listát? + alert title + Delete member message? - Csoporttag üzenetének törlése? + Törli a tag üzenetét? No comment provided by engineer. Delete message? - Üzenet törlése? + Törli az üzenetet? No comment provided by engineer. Delete messages Üzenetek törlése - No comment provided by engineer. + alert button Delete messages after @@ -2378,7 +2549,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete old database? - Régi adatbázis törlése? + Törli a régi adatbázist? No comment provided by engineer. @@ -2388,7 +2559,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete pending connection? - Függőben lévő ismerőskérelem törlése? + Törli a függőben lévő meghívót? No comment provided by engineer. @@ -2398,9 +2569,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete queue - Sorbaállítás törlése + Sorba állítás törlése server test step + + Delete report + Jelentés törlése + No comment provided by engineer. + Delete up to 20 messages at once. Legfeljebb 20 üzenet egyszerre való törlése. @@ -2408,7 +2584,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete user profile? - Felhasználói profil törlése? + Törli a felhasználói profilt? No comment provided by engineer. @@ -2423,12 +2599,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Deleted at - Törölve ekkor: + Törölve No comment provided by engineer. Deleted at: %@ - Törölve ekkor: %@ + Törölve: %@ copied message info @@ -2448,7 +2624,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delivery receipts are disabled! - A kézbesítési jelentések ki vannak kapcsolva! + A kézbesítési jelentések le vannak tiltva! No comment provided by engineer. @@ -2468,7 +2644,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Desktop app version %@ is not compatible with this app. - A számítógép-alkalmazás verziója %@ nem kompatibilis ezzel az alkalmazással. + A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. No comment provided by engineer. @@ -2478,17 +2654,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Destination server address of %@ is incompatible with forwarding server %@ settings. - A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbító kiszolgáló beállításaival. + A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbítókiszolgáló beállításaival. No comment provided by engineer. Destination server error: %@ - Célkiszolgáló hiba: %@ + Célkiszolgáló-hiba: %@ snd error text Destination server version of %@ is incompatible with forwarding server %@. - A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbító kiszolgálóval. + A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbítókiszolgálóval. No comment provided by engineer. @@ -2533,7 +2709,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Different names, avatars and transport isolation. - Különböző nevek, profilképek és átvitel-izoláció. + Különböző nevek, profilképek és átvitelizoláció. No comment provided by engineer. @@ -2548,7 +2724,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Direct messages between members are prohibited. - A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. + A tagok közötti közvetlen üzenetek le vannak tiltva. No comment provided by engineer. @@ -2561,9 +2737,19 @@ Ez az Ön egyszer használható meghívó-hivatkozása! SimpleX-zár kikapcsolása authentication reason + + Disable automatic message deletion? + Letiltja az automatikus üzenettörlést? + alert title + + + Disable delete messages + Üzenetek törlésének letiltása + alert button + Disable for all - Letiltás az összes tag számára + Letiltás No comment provided by engineer. @@ -2588,17 +2774,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Disappearing messages are prohibited. - Az eltűnő üzenetek küldése le van tiltva ebben a csoportban. + Az eltűnő üzenetek küldése le van tiltva. No comment provided by engineer. Disappears at - Eltűnik ekkor: + Eltűnik No comment provided by engineer. Disappears at: %@ - Eltűnik ekkor: %@ + Eltűnik: %@ copied message info @@ -2608,12 +2794,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Disconnect desktop? - Számítógép leválasztása? + Leválasztja a számítógépet? No comment provided by engineer. Discover and join groups - Helyi csoportok felfedezése és csatlakozás + Csoportok felfedezése és csatlakozás No comment provided by engineer. @@ -2623,17 +2809,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do NOT send messages directly, even if your or destination server does not support private routing. - Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. No comment provided by engineer. Do NOT use SimpleX for emergency calls. - NE használja a SimpleX-et segélyhívásokhoz. + NE használja a SimpleXet segélyhívásokhoz. No comment provided by engineer. Do NOT use private routing. - Ne használjon privát útválasztást. + NE használjon privát útválasztást. No comment provided by engineer. @@ -2643,7 +2829,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do not send history to new members. - Az előzmények ne kerüljenek elküldésre az új tagok számára. + Az előzmények ne legyenek elküldve az új tagok számára. No comment provided by engineer. @@ -2651,6 +2837,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Ne használja a hitelesítőadatokat proxyval. No comment provided by engineer. + + Documents: + Dokumentumok: + No comment provided by engineer. + Don't create address Ne hozzon létre címet @@ -2661,9 +2852,19 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Ne engedélyezze No comment provided by engineer. + + Don't miss important messages. + Ne maradjon le a fontos üzenetekről. + No comment provided by engineer. + Don't show again Ne mutasd újra + alert action + + + Done + Kész No comment provided by engineer. @@ -2675,7 +2876,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Download Letöltés alert button - chat item action +chat item action Download errors @@ -2719,7 +2920,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Duplicate display name! - Duplikált megjelenített név! + Duplikált megjelenítendő név! No comment provided by engineer. @@ -2739,7 +2940,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Edit group profile - A csoport profiljának szerkesztése + Csoportprofil szerkesztése No comment provided by engineer. @@ -2752,9 +2953,9 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Engedélyezés (felülírások megtartásával) No comment provided by engineer. - - Enable Flux - Flux engedélyezése + + Enable Flux in Network & servers settings for better metadata privacy. + A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. No comment provided by engineer. @@ -2769,8 +2970,8 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable automatic message deletion? - Automatikus üzenet törlés engedélyezése? - No comment provided by engineer. + Engedélyezi az automatikus üzenettörlést? + alert title Enable camera access @@ -2789,7 +2990,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable instant notifications? - Azonnali értesítések engedélyezése? + Engedélyezi az azonnali értesítéseket? No comment provided by engineer. @@ -2804,7 +3005,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable periodic notifications? - Időszakos értesítések engedélyezése? + Engedélyezi az időszakos értesítéseket? No comment provided by engineer. @@ -2814,7 +3015,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable self-destruct passcode - Önmegsemmisítő jelkód engedélyezése + Önmegsemmisítő-jelkód engedélyezése set passcode view @@ -2824,7 +3025,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enabled for - Számukra engedélyezve: + Számukra engedélyezve No comment provided by engineer. @@ -2834,7 +3035,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encrypt database? - Adatbázis titkosítása? + Titkosítja az adatbázist? No comment provided by engineer. @@ -2859,7 +3060,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encrypted message: app is stopped - Titkosított üzenet: az alkalmazás leállt + Titkosított üzenet: az alkalmazás megállt notification @@ -2889,17 +3090,22 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encryption re-negotiation error - Titkosítás újraegyeztetési hiba + Hiba történt a titkosítás újraegyeztetésekor message decrypt error item Encryption re-negotiation failed. - Sikertelen titkosítás-újraegyeztetés. + Nem sikerült a titkosítást újraegyeztetni. + No comment provided by engineer. + + + Encryption renegotiation in progress. + A titkosítás újraegyeztetése folyamatban van. No comment provided by engineer. Enter Passcode - Jelkód megadása + Adja meg a jelkódot No comment provided by engineer. @@ -2909,27 +3115,27 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enter group name… - Csoportnév megadása… + Adja meg a csoport nevét… No comment provided by engineer. Enter passphrase - Jelmondat megadása + Adja meg a jelmondatot No comment provided by engineer. Enter passphrase… - Jelmondat megadása… + Adja meg a jelmondatot… No comment provided by engineer. Enter password above to show! - Jelszó megadása a megjelenítéshez! + Adja meg a jelszót fentebb a megjelenítéshez! No comment provided by engineer. Enter server manually - Kiszolgáló megadása kézzel + Adja meg a kiszolgálót kézzel No comment provided by engineer. @@ -2939,12 +3145,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enter welcome message… - Üdvözlőüzenet megadása… + Adja meg az üdvözlőüzenetet… placeholder Enter welcome message… (optional) - Üdvözlőüzenet megadása… (nem kötelező) + Adja meg az üdvözlőüzenetet… (nem kötelező) placeholder @@ -2959,252 +3165,292 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error aborting address change - Hiba a cím megváltoztatásának megszakításakor + Hiba történt a cím módosításának megszakításakor No comment provided by engineer. Error accepting conditions - Hiba a feltételek elfogadásakor + Hiba történt a feltételek elfogadásakor alert title Error accepting contact request - Hiba történt a kapcsolatkérés elfogadásakor + Hiba történt a meghívási kérés elfogadásakor No comment provided by engineer. + + Error accepting member + Hiba a tag befogadásakor + alert title + Error adding member(s) - Hiba a tag(ok) hozzáadásakor + Hiba történt a tag(ok) hozzáadásakor No comment provided by engineer. Error adding server - Hiba a kiszolgáló hozzáadásakor + Hiba történt a kiszolgáló hozzáadásakor alert title Error changing address - Hiba a cím megváltoztatásakor + Hiba történt a cím módosításakor No comment provided by engineer. Error changing connection profile - Hiba a kapcsolati profilra való váltáskor + Hiba történt a kapcsolati profilra való váltáskor No comment provided by engineer. Error changing role - Hiba a szerepkör megváltoztatásakor + Hiba történt a szerepkör módosításakor No comment provided by engineer. Error changing setting - Hiba a beállítás megváltoztatásakor + Hiba történt a beállítás módosításakor No comment provided by engineer. Error changing to incognito! - Hiba az inkognitóprofilra való váltáskor! + Hiba történt az inkognitóprofilra való váltáskor! + No comment provided by engineer. + + + Error checking token status + Hiba történt a token állapotának ellenőrzésekor No comment provided by engineer. Error connecting to forwarding server %@. Please try later. - Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. No comment provided by engineer. Error creating address - Hiba a cím létrehozásakor + Hiba történt a cím létrehozásakor No comment provided by engineer. Error creating group - Hiba a csoport létrehozásakor + Hiba történt a csoport létrehozásakor No comment provided by engineer. Error creating group link - Hiba a csoporthivatkozás létrehozásakor + Hiba történt a csoporthivatkozás létrehozásakor No comment provided by engineer. + + Error creating list + Hiba történt a lista létrehozásakor + alert title + Error creating member contact - Hiba az ismerőssel történő kapcsolat létrehozásában + Hiba történt a partnerrel történő kapcsolat létrehozásában No comment provided by engineer. Error creating message - Hiba az üzenet létrehozásakor + Hiba történt az üzenet létrehozásakor No comment provided by engineer. Error creating profile! - Hiba a profil létrehozásakor! + Hiba történt a profil létrehozásakor! + No comment provided by engineer. + + + Error creating report + Hiba történt a jelentés létrehozásakor No comment provided by engineer. Error decrypting file - Hiba a fájl visszafejtésekor + Hiba történt a fájl visszafejtésekor No comment provided by engineer. Error deleting chat database - Hiba a csevegési adatbázis törlésekor + Hiba történt a csevegési adatbázis törlésekor No comment provided by engineer. + + Error deleting chat with member + Hiba a taggal való csevegés törlésekor + alert title + Error deleting chat! - Hiba a csevegés törlésekor! + Hiba történt a csevegés törlésekor! No comment provided by engineer. Error deleting connection - Hiba a kapcsolat törlésekor + Hiba történt a kapcsolat törlésekor No comment provided by engineer. Error deleting database - Hiba az adatbázis törlésekor + Hiba történt az adatbázis törlésekor No comment provided by engineer. Error deleting old database - Hiba a régi adatbázis törlésekor + Hiba történt a régi adatbázis törlésekor No comment provided by engineer. Error deleting token - Hiba a token törlésekor + Hiba történt a token törlésekor No comment provided by engineer. Error deleting user profile - Hiba a felhasználó-profil törlésekor + Hiba történt a felhasználói profil törlésekor No comment provided by engineer. Error downloading the archive - Hiba az archívum letöltésekor + Hiba történt az archívum letöltésekor No comment provided by engineer. Error enabling delivery receipts! - Hiba a kézbesítési jelentések engedélyezésekor! + Hiba történt a kézbesítési jelentések engedélyezésekor! No comment provided by engineer. Error enabling notifications - Hiba az értesítések engedélyezésekor + Hiba történt az értesítések engedélyezésekor No comment provided by engineer. Error encrypting database - Hiba az adatbázis titkosításakor + Hiba történt az adatbázis titkosításakor No comment provided by engineer. Error exporting chat database - Hiba a csevegési adatbázis exportálásakor + Hiba történt a csevegési adatbázis exportálásakor No comment provided by engineer. Error exporting theme: %@ - Hiba a téma exportálásakor: %@ + Hiba történt a téma exportálásakor: %@ No comment provided by engineer. Error importing chat database - Hiba a csevegési adatbázis importálásakor + Hiba történt a csevegési adatbázis importálásakor No comment provided by engineer. Error joining group - Hiba a csoporthoz való csatlakozáskor + Hiba történt a csoporthoz való csatlakozáskor No comment provided by engineer. Error loading servers - Hiba a kiszolgálók betöltésekor + Hiba történt a kiszolgálók betöltésekor alert title Error migrating settings - Hiba a beallítások átköltöztetésekor + Hiba történt a beállítások átköltöztetésekor No comment provided by engineer. Error opening chat - Hiba a csevegés megnyitásakor + Hiba történt a csevegés megnyitásakor No comment provided by engineer. Error receiving file - Hiba a fájl fogadásakor + Hiba történt a fájl fogadásakor alert title Error reconnecting server - Hiba a kiszolgálóhoz való újrakapcsolódáskor + Hiba történt a kiszolgálóhoz való újrakapcsolódáskor No comment provided by engineer. Error reconnecting servers - Hiba a kiszolgálókhoz való újrakapcsolódáskor + Hiba történt a kiszolgálókhoz való újrakapcsolódáskor No comment provided by engineer. + + Error registering for notifications + Hiba történt az értesítések regisztrálásakor + alert title + Error removing member - Hiba a tag eltávolításakor - No comment provided by engineer. + Hiba történt a tag eltávolításakor + alert title + + + Error reordering lists + Hiba történt a listák újrarendezésekor + alert title Error resetting statistics - Hiba a statisztikák visszaállításakor + Hiba történt a statisztikák visszaállításakor No comment provided by engineer. Error saving ICE servers - Hiba az ICE-kiszolgálók mentésekor + Hiba történt az ICE-kiszolgálók mentésekor No comment provided by engineer. + + Error saving chat list + Hiba történt a csevegési lista mentésekor + alert title + Error saving group profile - Hiba a csoportprofil mentésekor + Hiba történt a csoportprofil mentésekor No comment provided by engineer. Error saving passcode - Hiba a jelkód mentésekor + Hiba történt a jelkód mentésekor No comment provided by engineer. Error saving passphrase to keychain - Hiba a jelmondat kulcstartóba történő mentésekor + Hiba történt a jelmondat kulcstartóba történő mentésekor No comment provided by engineer. Error saving servers - Hiba a kiszolgálók mentésekor + Hiba történt a kiszolgálók mentésekor alert title Error saving settings - Hiba a beállítások mentésekor + Hiba történt a beállítások mentésekor when migrating Error saving user password - Hiba a felhasználói jelszó mentésekor + Hiba történt a felhasználó jelszavának mentésekor No comment provided by engineer. Error scanning code: %@ - Hiba a kód beolvasásakor: %@ + Hiba történt a kód beolvasásakor: %@ No comment provided by engineer. Error sending email - Hiba az e-mail küldésekor + Hiba történt az e-mail elküldésekor No comment provided by engineer. @@ -3214,7 +3460,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error sending message - Hiba az üzenet küldésekor + Hiba történt az üzenet elküldésekor No comment provided by engineer. @@ -3224,42 +3470,47 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error starting chat - Hiba a csevegés elindításakor + Hiba történt a csevegés elindításakor No comment provided by engineer. Error stopping chat - Hiba a csevegés megállításakor + Hiba történt a csevegés megállításakor No comment provided by engineer. Error switching profile - Hiba a profilváltáskor + Hiba történt a profilváltáskor No comment provided by engineer. Error switching profile! - Hiba a profilváltásakor! + Hiba történt a profilváltáskor! alertTitle Error synchronizing connection - Hiba a kapcsolat szinkronizálásakor + Hiba történt a kapcsolat szinkronizálásakor + No comment provided by engineer. + + + Error testing server connection + Hiba történt a kiszolgáló kapcsolatának tesztelésekor No comment provided by engineer. Error updating group link - Hiba a csoporthivatkozás frissítésekor + Hiba történt a csoporthivatkozás frissítésekor No comment provided by engineer. Error updating message - Hiba az üzenet frissítésekor + Hiba történt az üzenet frissítésekor No comment provided by engineer. Error updating server - Hiba a kiszolgáló frissítésekor + Hiba történt a kiszolgáló frissítésekor alert title @@ -3269,17 +3520,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error updating user privacy - Hiba a felhasználói adatvédelem frissítésekor + Hiba történt a felhasználói adatvédelem frissítésekor No comment provided by engineer. Error uploading the archive - Hiba az archívum feltöltésekor + Hiba történt az archívum feltöltésekor No comment provided by engineer. Error verifying passphrase: - Hiba a jelmondat hitelesítésekor: + Hiba történt a jelmondat hitelesítésekor: No comment provided by engineer. @@ -3290,7 +3541,9 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error: %@ Hiba: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3327,6 +3580,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kibontás chat item action + + Expired + Lejárt + token status text + Export database Adatbázis exportálása @@ -3367,20 +3625,35 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Gyors és nem kell várni, amíg a feladó online lesz! No comment provided by engineer. + + Faster deletion of groups. + Gyorsabb csoporttörlés. + No comment provided by engineer. + Faster joining and more reliable messages. Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés. No comment provided by engineer. + + Faster sending messages. + Gyorsabb üzenetküldés. + No comment provided by engineer. + Favorite Kedvenc swipe action + + Favorites + Kedvencek + No comment provided by engineer. + File error Fájlhiba - No comment provided by engineer. + file error alert title File errors: @@ -3389,24 +3662,31 @@ Ez az Ön egyszer használható meghívó-hivatkozása! %@ alert message + + File is blocked by server operator: +%@. + A kiszolgáló üzemeltetője letiltotta a fájlt: +%@. + file error text + File not found - most likely file was deleted or cancelled. - A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták. + A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták. file error text File server error: %@ - Fájlkiszolgáló hiba: %@ + Fájlkiszolgáló-hiba: %@ file error text File status - Fájlállapot + Fájl állapota No comment provided by engineer. File status: %@ - Fájlállapot: %@ + Fájl állapota: %@ copied message info @@ -3446,7 +3726,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Files and media are prohibited. - A fájlok- és a médiatartalmak le vannak tiltva ebben a csoportban. + A fájlok- és a médiatartalmak küldése le van tiltva. No comment provided by engineer. @@ -3506,7 +3786,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Fix not supported by contact - Ismerős általi javítás nem támogatott + Partner általi javítás nem támogatott No comment provided by engineer. @@ -3514,6 +3794,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Csoporttag általi javítás nem támogatott No comment provided by engineer. + + For all moderators + Az összes moderátor számára + No comment provided by engineer. + For chat profile %@: A(z) %@ nevű csevegési profilhoz: @@ -3526,7 +3811,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. - Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. + Például, ha a partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. + No comment provided by engineer. + + + For me + Csak magamnak No comment provided by engineer. @@ -3546,7 +3836,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Forward %d message(s)? - %d üzenet továbbítása? + Továbbít %d üzenetet? alert title @@ -3561,7 +3851,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Forward messages without files? - Üzenetek továbbítása fájlok nélkül? + Továbbítja az üzeneteket fájlok nélkül? alert message @@ -3576,7 +3866,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Forwarded from - Továbbítva innen: + Továbbítva innen No comment provided by engineer. @@ -3586,30 +3876,30 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Forwarding server %@ failed to connect to destination server %@. Please try later. - A(z) %@ továbbító-kiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később. + A(z) %@ továbbítókiszolgáló nem tudott kapcsolódni a(z) %@ célkiszolgálóhoz. Próbálja meg később. No comment provided by engineer. Forwarding server address is incompatible with network settings: %@. - A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@. + A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@. No comment provided by engineer. Forwarding server version is incompatible with network settings: %@. - A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@. + A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@. No comment provided by engineer. Forwarding server: %1$@ Destination server error: %2$@ - Továbbító-kiszolgáló: %1$@ -Célkiszolgáló hiba:%2$@ + Továbbítókiszolgáló: %1$@ +Célkiszolgáló-hiba: %2$@ snd error text Forwarding server: %1$@ Error: %2$@ - Továbbító-kiszolgáló: %1$@ + Továbbítókiszolgáló: %1$@ Hiba: %2$@ snd error text @@ -3635,12 +3925,12 @@ Hiba: %2$@ Fully decentralized – visible only to members. - Teljesen decentralizált - csak a tagok számára látható. + Teljesen decentralizált – csak a tagok számára látható. No comment provided by engineer. Fully re-implemented - work in background! - Teljesen újra implementálva - háttérben történő működés! + Teljesen újra implementálva – háttérben történő működés! No comment provided by engineer. @@ -3653,6 +3943,11 @@ Hiba: %2$@ GIF-ek és matricák No comment provided by engineer. + + Get notified when mentioned. + Kapjon értesítést, ha megemlítik. + No comment provided by engineer. + Good afternoon! Jó napot! @@ -3685,7 +3980,7 @@ Hiba: %2$@ Group full name (optional) - Csoport teljes neve (nem kötelező) + A csoport teljes neve (nem kötelező) No comment provided by engineer. @@ -3725,7 +4020,7 @@ Hiba: %2$@ Group moderation - Csoport moderáció + Csoport moderálása No comment provided by engineer. @@ -3750,12 +4045,17 @@ Hiba: %2$@ Group will be deleted for all members - this cannot be undone! - A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az összes tag számára – ez a művelet nem vonható vissza! No comment provided by engineer. Group will be deleted for you - this cannot be undone! - A csoport törlésre kerül az Ön számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza! + No comment provided by engineer. + + + Groups + Csoportok No comment provided by engineer. @@ -3763,6 +4063,11 @@ Hiba: %2$@ Súgó No comment provided by engineer. + + Help admins moderating their groups. + Segítsen az adminisztrátoroknak a csoportjaik moderálásában. + No comment provided by engineer. + Hidden Se név, se üzenet @@ -3795,7 +4100,7 @@ Hiba: %2$@ Hide: - Elrejtés: + Elrejtve: No comment provided by engineer. @@ -3805,7 +4110,7 @@ Hiba: %2$@ History is not sent to new members. - Az előzmények nem kerülnek elküldésre az új tagok számára. + Az előzmények nem lesznek elküldve az új tagok számára. No comment provided by engineer. @@ -3823,6 +4128,11 @@ Hiba: %2$@ Hogyan segíti az adatvédelmet No comment provided by engineer. + + How it works + Hogyan működik + alert button + How to Hogyan @@ -3830,12 +4140,12 @@ Hiba: %2$@ How to use it - Hogyan használja + Használati útmutató No comment provided by engineer. How to use your servers - Saját kiszolgálók használata + Hogyan használja a saját kiszolgálóit No comment provided by engineer. @@ -3860,17 +4170,17 @@ Hiba: %2$@ If you enter this passcode when opening the app, all app data will be irreversibly removed! - Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül! + Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! No comment provided by engineer. If you enter your self-destruct passcode while opening the app: - Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: + Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot: No comment provided by engineer. If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). - Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor felajánlásra kerül az adatbázis átköltöztetése). + Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). No comment provided by engineer. @@ -3895,7 +4205,7 @@ Hiba: %2$@ Immune to spam - Spam és visszaélések elleni védelem + Védett a kéretlen tartalommal szemben No comment provided by engineer. @@ -3905,7 +4215,7 @@ Hiba: %2$@ Import chat database? - Csevegési adatbázis importálása? + Importálja a csevegési adatbázist? No comment provided by engineer. @@ -3957,7 +4267,7 @@ További fejlesztések hamarosan! In reply to - Válasz neki + Válaszul erre No comment provided by engineer. @@ -3965,6 +4275,16 @@ További fejlesztések hamarosan! Bejövő hívás csengőhangja No comment provided by engineer. + + Inappropriate content + Kifogásolt tartalom + report reason + + + Inappropriate profile + Kifogásolt profil + report reason + Incognito Inkognitó @@ -3982,7 +4302,7 @@ További fejlesztések hamarosan! Incognito mode protects your privacy by using a new random profile for each contact. - Az inkognitómód védi személyes adatait azáltal, hogy az összes ismerőséhez új, véletlenszerű profilt használ. + Az inkognitómód úgy védi a személyes adatait, hogy az összes partneréhez új, véletlenszerű profilt használ. No comment provided by engineer. @@ -4037,13 +4357,13 @@ További fejlesztések hamarosan! Instant - Azonnal + Azonnali No comment provided by engineer. Instant push notifications will be hidden! - Az azonnali push-értesítések elrejtésre kerülnek! + Az azonnali push-értesítések el lesznek rejtve! No comment provided by engineer. @@ -4057,6 +4377,31 @@ További fejlesztések hamarosan! Kezelőfelület színei No comment provided by engineer. + + Invalid + Érvénytelen + token status text + + + Invalid (bad token) + Érvénytelen (hibás token) + token status text + + + Invalid (expired) + Érvénytelen (lejárt) + token status text + + + Invalid (unregistered) + Érvénytelen (nincs regisztrálva) + token status text + + + Invalid (wrong topic) + Érvénytelen (rossz topic) + token status text + Invalid QR code Érvénytelen QR-kód @@ -4139,17 +4484,17 @@ További fejlesztések hamarosan! Irreversible message deletion is prohibited. - Az üzenetek végleges törlése le van tiltva ebben a csoportban. + Az üzenetek végleges törlése le van tiltva. No comment provided by engineer. It allows having many anonymous connections without any shared data between them in a single chat profile. - Lehetővé teszi, hogy egyetlen csevegőprofilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük. + Lehetővé teszi, hogy egyetlen csevegési profilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük. No comment provided by engineer. It can happen when you or your connection used the old database backup. - Ez akkor fordulhat elő, ha Ön vagy az ismerőse régi adatbázis biztonsági mentést használt. + Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta. No comment provided by engineer. @@ -4159,7 +4504,7 @@ További fejlesztések hamarosan! 3. The connection was compromised. Ez akkor fordulhat elő, ha: 1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak. -2. Az üzenet visszafejtése sikertelen volt, mert vagy az ismerőse régebbi adatbázis biztonsági mentést használt. +2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere egy régi adatbázis biztonsági mentését használta. 3. A kapcsolat sérült. No comment provided by engineer. @@ -4217,7 +4562,7 @@ További fejlesztések hamarosan! Join your group? This is your link for group %@! Csatlakozik a csoportjához? -Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! +Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No comment provided by engineer. @@ -4227,7 +4572,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Keep - Megtart + Megtartás alert action @@ -4242,7 +4587,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Keep unused invitation? - Fel nem használt meghívó megtartása? + Megtartja a fel nem használt meghívót? alert title @@ -4287,7 +4632,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Leave chat? - Csevegés elhagyása? + Elhagyja a csevegést? No comment provided by engineer. @@ -4297,7 +4642,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Leave group? - Csoport elhagyása? + Elhagyja a csoportot? No comment provided by engineer. @@ -4330,6 +4675,21 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Társított számítógépek No comment provided by engineer. + + List + Lista + swipe action + + + List name and emoji should be different for all lists. + Az összes lista nevének és emodzsijának különbözőnek kell lennie. + No comment provided by engineer. + + + List name... + Lista neve… + No comment provided by engineer. + Live message! Élő üzenet! @@ -4372,7 +4732,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. - Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. + Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. No comment provided by engineer. @@ -4402,7 +4762,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Media & file servers - Média és fájlkiszolgálók + Média- és fájlkiszolgálók No comment provided by engineer. @@ -4415,69 +4775,94 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Tag No comment provided by engineer. + + Member admission + Tagbefogadás + No comment provided by engineer. + Member inactive Inaktív tag item status text + + Member reports + Tagok jelentései + chat feature + Member role will be changed to "%@". All chat members will be notified. - A tag szerepeköre meg fog változni a következőre: "%@". A csevegés tagjai értesítést fognak kapni. + A tag szerepköre a következőre fog módosulni: „%@”. A csevegés összes tagja értesítést fog kapni. No comment provided by engineer. Member role will be changed to "%@". All group members will be notified. - A tag szerepköre meg fog változni erre: „%@”. A csoportban az összes tag értesítve lesz. + A tag szerepköre a következőre fog módosulni: „%@”. A csoport az összes tagja értesítést fog kapni. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. - A tag szerepköre meg fog változni erre: „%@”. A tag új meghívást fog kapni. + A tag szerepköre a következőre fog módosulni: „%@”. A tag új meghívást fog kapni. No comment provided by engineer. Member will be removed from chat - this cannot be undone! - A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni! + A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! No comment provided by engineer. Member will be removed from group - this cannot be undone! - A tag eltávolítása a csoportból - ez a művelet nem vonható vissza! + A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! No comment provided by engineer. + + Member will join the group, accept member? + A tag csatlakozni akar a csoporthoz, befogadja a tagot? + alert message + Members can add message reactions. - Csoporttagok üzenetreakciókat adhatnak hozzá. + A tagok reakciókat adhatnak hozzá az üzenetekhez. No comment provided by engineer. Members can irreversibly delete sent messages. (24 hours) - A csoport tagjai véglegesen törölhetik az elküldött üzeneteiket. (24 óra) + A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) + No comment provided by engineer. + + + Members can report messsages to moderators. + A tagok jelenthetik az üzeneteket a moderátorok felé. No comment provided by engineer. Members can send SimpleX links. - A csoport tagjai küldhetnek SimpleX-hivatkozásokat. + A tagok küldhetnek SimpleX-hivatkozásokat. No comment provided by engineer. Members can send direct messages. - A csoport tagjai küldhetnek egymásnak közvetlen üzeneteket. + A tagok küldhetnek egymásnak közvetlen üzeneteket. No comment provided by engineer. Members can send disappearing messages. - A csoport tagjai küldhetnek eltűnő üzeneteket. + A tagok küldhetnek eltűnő üzeneteket. No comment provided by engineer. Members can send files and media. - A csoport tagjai küldhetnek fájlokat és médiatartalmakat. + A tagok küldhetnek fájlokat és médiatartalmakat. No comment provided by engineer. Members can send voice messages. - A csoport tagjai küldhetnek hangüzeneteket. + A tagok küldhetnek hangüzeneteket. + No comment provided by engineer. + + + Mention members 👋 + Tagok említése 👋 No comment provided by engineer. @@ -4517,7 +4902,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message queue info - Üzenet-sorbaállítási információ + Üzenetsorbaállítási információ No comment provided by engineer. @@ -4527,12 +4912,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message reactions are prohibited in this chat. - Az üzenetreakciók küldése le van tiltva ebben a csevegésben. + A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben. No comment provided by engineer. Message reactions are prohibited. - Az üzenetreakciók küldése le van tiltva ebben a csoportban. + A reakciók hozzáadása az üzenetekhez le van tiltva. No comment provided by engineer. @@ -4547,7 +4932,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message shape - Üzenetbuborék formája + Üzenetbuborék alakja No comment provided by engineer. @@ -4557,12 +4942,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message status - Üzenetállapot + Üzenet állapota No comment provided by engineer. Message status: %@ - Üzenetállapot: %@ + Üzenet állapota: %@ copied message info @@ -4587,9 +4972,14 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages from %@ will be shown! - A(z) %@ által írt üzenetek megjelennek! + %@ összes üzenete meg fog jelenni! No comment provided by engineer. + + Messages in this chat will never be deleted. + Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. + alert message + Messages received Fogadott üzenetek @@ -4602,17 +4992,17 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages were deleted after you selected them. - Az üzeneteket törölték miután kiválasztotta őket. + Az üzeneteket törölték miután kijelölte őket. alert message Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery. - Az üzeneteket, fájlokat és hívásokat **végpontok közötti titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi. + Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve. No comment provided by engineer. Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. - Az üzeneteket, fájlokat és hívásokat **végpontok közötti kvantumrezisztens titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi. + Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve. No comment provided by engineer. @@ -4657,7 +5047,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Migration error: - Átköltöztetés hiba: + Átköltöztetési hiba: No comment provided by engineer. @@ -4682,14 +5072,19 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Moderated at - Moderálva ekkor: + Moderálva No comment provided by engineer. Moderated at: %@ - Moderálva ekkor: %@ + Moderálva: %@ copied message info + + More + Továbbiak + swipe action + More improvements are coming soon! Hamarosan további fejlesztések érkeznek! @@ -4707,18 +5102,23 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Most likely this connection is deleted. - Valószínűleg ez a kapcsolat törlésre került. + Valószínűleg ez a kapcsolat törölve lett. item status description Multiple chat profiles - Több csevegőprofil + Több csevegési profil No comment provided by engineer. Mute Némítás - swipe action + notification label action + + + Mute all + Összes némítása + notification label action Muted when inactive! @@ -4737,7 +5137,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Network connection - Internetkapcsolat + Hálózati kapcsolat No comment provided by engineer. @@ -4747,7 +5147,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Network issues - message expired after many attempts to send it. - Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt. + Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. snd error text @@ -4757,7 +5157,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Network operator - Hálózati üzemeltető + Hálózatüzemeltető No comment provided by engineer. @@ -4770,6 +5170,11 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Hálózat állapota No comment provided by engineer. + + New + Új + token status text + New Passcode Új jelkód @@ -4787,7 +5192,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! New chat - Új beszélgetés + Új csevegés No comment provided by engineer. @@ -4797,7 +5202,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! New contact request - Új kapcsolatkérés + Új meghívási kérés notification @@ -4812,7 +5217,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! New display name - Új megjelenítési név + Új megjelenítendő név No comment provided by engineer. @@ -4835,6 +5240,11 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Új tag szerepköre No comment provided by engineer. + + New member wants to join the group. + Új tag szeretne csatlakozni a csoporthoz. + rcv group event chat item + New message Új üzenet @@ -4860,14 +5270,34 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincs alkalmazás jelszó Authentication unavailable + + No chats + Nincsenek csevegések + No comment provided by engineer. + + + No chats found + Nem találhatók csevegések + No comment provided by engineer. + + + No chats in list %@ + Nincsenek csevegések a(z) %@ nevű listában + No comment provided by engineer. + + + No chats with members + Nincsenek csevegések a tagokkal + No comment provided by engineer. + No contacts selected - Nincs kiválasztva ismerős + Nincs partner kijelölve No comment provided by engineer. No contacts to add - Nincs hozzáadandó ismerős + Nincs hozzáadandó partner No comment provided by engineer. @@ -4877,7 +5307,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No device token! - Nincs kiszüléktoken! + Nincs készüléktoken! No comment provided by engineer. @@ -4910,6 +5340,11 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincsenek média- és fájlkiszolgálók. servers error + + No message + Nincs üzenet + No comment provided by engineer. + No message servers. Nincsenek üzenet-kiszolgálók. @@ -4952,12 +5387,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No servers to receive files. - Nincsenek fájlfogadó-kiszolgálók. + Nincsenek fájlfogadási kiszolgálók. servers error No servers to receive messages. - Nincsenek üzenetfogadó-kiszolgálók. + Nincsenek üzenetfogadási kiszolgálók. servers error @@ -4965,9 +5400,19 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincsenek fájlküldő-kiszolgálók. servers error + + No token! + Nincs token! + alert title + + + No unread chats + Nincsenek olvasatlan csevegések + No comment provided by engineer. + No user identifiers. - Nincsenek felhasználó-azonosítók. + Nincsenek felhasználói azonosítók. No comment provided by engineer. @@ -4975,9 +5420,14 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nem kompatibilis! No comment provided by engineer. + + Notes + Jegyzetek + No comment provided by engineer. + Nothing selected - Nincs kiválasztva semmi + Nincs semmi kijelölve No comment provided by engineer. @@ -4995,11 +5445,21 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Az értesítések le vannak tiltva! No comment provided by engineer. + + Notifications error + Értesítési hiba + alert title + Notifications privacy Értesítési adatvédelem No comment provided by engineer. + + Notifications status + Értesítések állapota + alert title + Now admins can: - delete members' messages. @@ -5022,7 +5482,8 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Ok Rendben - alert button + alert action +alert button Old database @@ -5031,7 +5492,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! One-time invitation link - Egyszer használható meghívó-hivatkozás + Egyszer használható meghívó No comment provided by engineer. @@ -5050,17 +5511,17 @@ VPN engedélyezése szükséges. Onion hosts will not be used. - Onion-kiszolgálók nem lesznek használva. + Az onion-kiszolgálók nem lesznek használva. No comment provided by engineer. Only chat owners can change preferences. - Csak a csevegés tulajdonosai módosíthatják a beállításokat. + Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat. No comment provided by engineer. Only client devices store user profiles, contacts, groups, and messages. - Csak az eszközök alkalmazásai tárolják a felhasználó-profilokat, névjegyeket, csoportokat és a **2 rétegű végpontok közötti titkosítással** küldött üzeneteket. + A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. No comment provided by engineer. @@ -5070,27 +5531,37 @@ VPN engedélyezése szükséges. Only group owners can change group preferences. - Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat. + Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat. No comment provided by engineer. Only group owners can enable files and media. - Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. + Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését. No comment provided by engineer. Only group owners can enable voice messages. - Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. + Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését. + No comment provided by engineer. + + + Only sender and moderators see it + Csak a küldő és a moderátorok látják + No comment provided by engineer. + + + Only you and moderators see it + Csak Ön és a moderátorok látják No comment provided by engineer. Only you can add message reactions. - Csak Ön adhat hozzá üzenetreakciókat. + Csak Ön adhat hozzá reakciókat az üzenetekhez. No comment provided by engineer. Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours) - Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti meg őket ). (24 óra) + Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) No comment provided by engineer. @@ -5110,33 +5581,33 @@ VPN engedélyezése szükséges. Only your contact can add message reactions. - Csak az ismerőse tud üzenetreakciókat küldeni. + Csak a partnere adhat hozzá reakciókat az üzenetekhez. No comment provided by engineer. Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours) - Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) No comment provided by engineer. Only your contact can make calls. - Csak az ismerőse tud hívást indítani. + Csak a partnere tud hívást indítani. No comment provided by engineer. Only your contact can send disappearing messages. - Csak az ismerőse tud eltűnő üzeneteket küldeni. + Csak a partnere tud eltűnő üzeneteket küldeni. No comment provided by engineer. Only your contact can send voice messages. - Csak az ismerőse tud hangüzeneteket küldeni. + Csak a partnere tud hangüzeneteket küldeni. No comment provided by engineer. Open Megnyitás - No comment provided by engineer. + alert action Open Settings @@ -5145,7 +5616,7 @@ VPN engedélyezése szükséges. Open changes - Változások megnyitása + Módosítások megtekintése No comment provided by engineer. @@ -5155,7 +5626,7 @@ VPN engedélyezése szükséges. Open chat console - Csevegés konzol megnyitása + Csevegési konzol megnyitása authentication reason @@ -5168,9 +5639,14 @@ VPN engedélyezése szükséges. Csoport megnyitása No comment provided by engineer. + + Open link? + Megnyitja a hivatkozást? + alert title + Open migration to another device - Átköltöztetés megkezdése egy másik eszközre + Átköltöztetés indítása egy másik eszközre authentication reason @@ -5185,7 +5661,7 @@ VPN engedélyezése szükséges. Operator server - Kiszolgáló üzemeltető + Kiszolgáló-üzemeltető alert title @@ -5218,6 +5694,11 @@ VPN engedélyezése szükséges. Vagy a privát megosztáshoz No comment provided by engineer. + + Organize chats into lists + Csevegések listákba szervezése + No comment provided by engineer. + Other További @@ -5247,7 +5728,7 @@ VPN engedélyezése szükséges. Passcode changed! - A jelkód megváltozott! + A jelkód módosult! No comment provided by engineer. @@ -5257,7 +5738,7 @@ VPN engedélyezése szükséges. Passcode not changed! - A jelkód nem változott! + A jelkód nem módosult! No comment provided by engineer. @@ -5272,7 +5753,7 @@ VPN engedélyezése szükséges. Password to show - Jelszó megjelenítése + Jelszó a megjelenítéshez No comment provided by engineer. @@ -5307,7 +5788,7 @@ VPN engedélyezése szükséges. Periodic - Rendszeresen + Időszakos No comment provided by engineer. @@ -5327,12 +5808,12 @@ VPN engedélyezése szükséges. Please ask your contact to enable calls. - Kérje meg az ismerősét, hogy engedélyezze a hívásokat. + Kérje meg a partnerét, hogy engedélyezze a hívásokat. No comment provided by engineer. Please ask your contact to enable sending voice messages. - Kérje meg az ismerősét, hogy engedélyezze a hangüzenetek küldését. + Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését. No comment provided by engineer. @@ -5344,17 +5825,17 @@ Minden további problémát osszon meg a fejlesztőkkel. Please check that you used the correct link or ask your contact to send you another one. - Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat. + Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat. No comment provided by engineer. Please check your network connection with %@ and try again. - Ellenőrizze a hálózati kapcsolatát a következővel: %@, és próbálja újra. + Ellenőrizze a hálózati kapcsolatát a vele: %@, és próbálja újra. No comment provided by engineer. Please check yours and your contact preferences. - Ellenőrizze a saját- és az ismerőse beállításait. + Ellenőrizze a saját- és a partnere beállításait. No comment provided by engineer. @@ -5376,17 +5857,17 @@ Hiba: %@ Please enter correct current passphrase. - Adja meg a helyes, jelenlegi jelmondatát. + Adja meg a helyes, jelenlegi jelmondatot. No comment provided by engineer. Please enter the previous password after restoring database backup. This action can not be undone. - Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. + Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. No comment provided by engineer. Please remember or store it securely - there is no way to recover a lost passcode! - Jegyezze fel vagy tárolja el biztonságosan - az elveszett jelkódot nem lehet visszaállítani! + Jegyezze fel vagy tárolja el biztonságosan – az elveszett jelkódot nem lehet visszaállítani! No comment provided by engineer. @@ -5406,9 +5887,29 @@ Hiba: %@ Please store passphrase securely, you will NOT be able to change it if you lose it. - Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni. + Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja módosítani. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Próbálja meg letiltani és újra engedélyezni az értesítéseket. + token info + + + Please wait for group moderators to review your request to join the group. + Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérelmét. + snd group event chat item + + + Please wait for token activation to complete. + Várjon, amíg a token aktiválása befejeződik. + token info + + + Please wait for token to be registered. + Várjon a token regisztrálására. + token info + Polish interface Lengyel kezelőfelület @@ -5431,7 +5932,7 @@ Hiba: %@ Preset server address - Előre beállított kiszolgáló címe + Az előre beállított kiszolgáló címe No comment provided by engineer. @@ -5456,12 +5957,22 @@ Hiba: %@ Privacy for your customers. - Az Ön ügyfeleinek adatvédelme. + Saját ügyfeleinek adatvédelme. + No comment provided by engineer. + + + Privacy policy and conditions of use. + Adatvédelmi szabályzat és felhasználási feltételek. No comment provided by engineer. Privacy redefined - Adatvédelem újraértelmezve + Újraértelmezett adatvédelem + No comment provided by engineer. + + + Private chats, groups and your contacts are not accessible to server operators. + A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára. No comment provided by engineer. @@ -5469,6 +5980,11 @@ Hiba: %@ Privát fájlnevek No comment provided by engineer. + + Private media file names. + Privát nevek a médiafájlokhoz. + No comment provided by engineer. + Private message routing Privát üzenet-útválasztás @@ -5491,7 +6007,7 @@ Hiba: %@ Private routing error - Privát útválasztáshiba + Privát útválasztási hiba No comment provided by engineer. @@ -5521,7 +6037,7 @@ Hiba: %@ Profile update will be sent to your contacts. - A profilfrissítés elküldésre került az ismerősök számára. + A profilfrissítés el lesz küldve a partnerei számára. alert message @@ -5531,17 +6047,22 @@ Hiba: %@ Prohibit irreversible message deletion. - Az üzenetek véglegesen való törlése le van tiltva. + Az elküldött üzenetek végleges törlése le van tiltva. No comment provided by engineer. Prohibit message reactions. - Az üzenetreakciók küldése le van tiltva. + A reakciók hozzáadása az üzenethez le van tiltva. No comment provided by engineer. Prohibit messages reactions. - Az üzenetreakciók tiltása. + A reakciók hozzáadása az üzenetekhez le van tiltva. + No comment provided by engineer. + + + Prohibit reporting messages to moderators. + Az üzenetek a moderátorok felé történő jelentésének megtiltása. No comment provided by engineer. @@ -5561,7 +6082,7 @@ Hiba: %@ Prohibit sending files and media. - Fájlok- és a médiatartalmak küldésének letiltása. + A fájlok- és a médiatartalmak küldése le van tiltva. No comment provided by engineer. @@ -5582,8 +6103,8 @@ Hiba: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben. -Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. + Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben. +Engedélyezze a *Hálózat és kiszolgálók* menüben. No comment provided by engineer. @@ -5598,7 +6119,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Protocol timeout per KB - Protokoll időtúllépése KB-onként + Protokoll időtúllépése kB-onként No comment provided by engineer. @@ -5628,7 +6149,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Quantum resistant encryption - Kvantumrezisztens titkosítás + Kvantumbiztos titkosítás No comment provided by engineer. @@ -5688,12 +6209,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Received at - Fogadva ekkor: + Fogadva No comment provided by engineer. Received at: %@ - Fogadva ekkor: %@ + Fogadva: %@ copied message info @@ -5723,7 +6244,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Receiving address will be changed to a different server. Address change will complete after sender comes online. - A fogadó cím egy másik kiszolgálóra változik. A címváltoztatás a feladó online állapotba kerülése után fejeződik be. + A fogadási cím egy másik kiszolgálóra fog módosulni. A cím módosítása a feladó online állapotba kerülése után fejeződik be. No comment provided by engineer. @@ -5733,7 +6254,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Receiving via - Fogadás a + Fogadás a következőn keresztül: No comment provided by engineer. @@ -5768,7 +6289,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Reconnect all servers? - Újrakapcsolódás az összes kiszolgálóhoz? + Újrakapcsolódik az összes kiszolgálóhoz? No comment provided by engineer. @@ -5778,22 +6299,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Reconnect server? - Újrakapcsolódás a kiszolgálóhoz? + Újrakapcsolódik a kiszolgálóhoz? No comment provided by engineer. Reconnect servers? - Újrakapcsolódás a kiszolgálókhoz? + Újrakapcsolódik a kiszolgálókhoz? No comment provided by engineer. Record updated at - A bejegyzés frissítve + Bejegyzés frissítve No comment provided by engineer. Record updated at: %@ - A bejegyzés frissítve: %@ + Bejegyzés frissítve: %@ copied message info @@ -5801,11 +6322,26 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csökkentett akkumulátor-használat No comment provided by engineer. + + Register + Regisztrálás + No comment provided by engineer. + + + Register notification token? + Regisztrálja az értesítési tokent? + token info + + + Registered + Regisztrálva + token status text + Reject Elutasítás reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5814,17 +6350,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Reject contact request - Kapcsolatkérés elutasítása + Meghívási kérés elutasítása No comment provided by engineer. + + Reject member? + Elutasítja a tagot? + alert title + Relay server is only used if necessary. Another party can observe your IP address. - A közvetítő-kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet. + A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. No comment provided by engineer. Relay server protects your IP address, but it can observe the duration of the call. - A közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. + A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. No comment provided by engineer. @@ -5834,7 +6375,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Remove archive? - Archívum eltávolítása? + Eltávolítja az archívumot? No comment provided by engineer. @@ -5849,17 +6390,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Remove member? - Biztosan eltávolítja? + Eltávolítja a tagot? No comment provided by engineer. Remove passphrase from keychain? - Jelmondat eltávolítása a kulcstartóból? + Eltávolítja a jelmondatot a kulcstartóból? No comment provided by engineer. Renegotiate - Újraegyzetetés + Újraegyeztetés No comment provided by engineer. @@ -5869,12 +6410,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Renegotiate encryption? - Titkosítás újraegyeztetése? + Újraegyezteti a titkosítást? No comment provided by engineer. Repeat connection request? - Kapcsolatkérés megismétlése? + Megismétli a meghívási kérést? No comment provided by engineer. @@ -5889,7 +6430,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Repeat join request? - Csatlakozáskérés megismétlése? + Megismétli a meghívási kérést? No comment provided by engineer. @@ -5902,6 +6443,61 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Válasz chat item action + + Report + Jelentés + chat item action + + + Report content: only group moderators will see it. + Tartalom jelentése: csak a csoport moderátorai látják. + report reason + + + Report member profile: only group moderators will see it. + Tag profiljának jelentése: csak a csoport moderátorai látják. + report reason + + + Report other: only group moderators will see it. + Egyéb jelentés: csak a csoport moderátorai látják. + report reason + + + Report reason? + Jelentés indoklása? + No comment provided by engineer. + + + Report sent to moderators + A jelentés el lett küldve a moderátoroknak + alert title + + + Report spam: only group moderators will see it. + Kéretlen tartalom jelentése: csak a csoport moderátorai látják. + report reason + + + Report violation: only group moderators will see it. + Szabálysértés jelentése: csak a csoport moderátorai látják. + report reason + + + Report: %@ + Jelentés: %@ + report in notification + + + Reporting messages to moderators is prohibited. + Az üzenetek jelentése a moderátorok felé le van tiltva. + No comment provided by engineer. + + + Reports + Jelentések + No comment provided by engineer. + Required Szükséges @@ -5924,7 +6520,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Reset all statistics? - Az összes statisztika visszaállítása? + Visszaállítja az összes statisztikát? No comment provided by engineer. @@ -5969,12 +6565,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Restore database backup? - Adatbázismentés visszaállítása? + Visszaállítja az adatbázismentést? No comment provided by engineer. Restore database error - Hiba az adatbázis visszaállításakor + Hiba történt az adatbázis visszaállításakor No comment provided by engineer. @@ -5992,10 +6588,15 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Feltételek felülvizsgálata No comment provided by engineer. - - Review later - Felülvizsgálat később - No comment provided by engineer. + + Review members + Tagok áttekintése + admission stage + + + Review members before admitting ("knocking"). + Tagok áttekintése a befogadás előtt (kopogtatás). + admission stage description Revoke @@ -6009,7 +6610,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Revoke file? - Fájl visszavonása? + Visszavonja a fájlt? No comment provided by engineer. @@ -6029,7 +6630,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SOCKS proxy - SOCKS proxy + SOCKS-proxy No comment provided by engineer. @@ -6046,16 +6647,21 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Save Mentés alert button - chat item action +chat item action Save (and notify contacts) - Mentés és az ismerősök értesítése + Mentés (és a partnerek értesítése) alert button + + Save admission settings? + Elmenti a befogadási beállításokat? + alert title + Save and notify contact - Mentés és az ismerős értesítése + Mentés és a partner értesítése alert button @@ -6078,6 +6684,11 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csoportprofil mentése No comment provided by engineer. + + Save list + Lista mentése + No comment provided by engineer. + Save passphrase and open chat Jelmondat mentése és a csevegés megnyitása @@ -6090,7 +6701,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Save preferences? - Beállítások mentése? + Menti a beállításokat? alert title @@ -6105,17 +6716,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Save servers? - Kiszolgálók mentése? + Menti a kiszolgálókat? alert title Save welcome message? - Üdvözlőüzenet mentése? + Menti az üdvözlőüzenetet? No comment provided by engineer. Save your profile? - Profil mentése? + Menti a profilt? alert title @@ -6125,12 +6736,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Saved WebRTC ICE servers will be removed - A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek + A mentett WebRTC ICE-kiszolgálók el lesznek távolítva No comment provided by engineer. Saved from - Elmentve innen: + Elmentve innen No comment provided by engineer. @@ -6160,7 +6771,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Scan QR code from desktop - QR-kód beolvasása számítógépről + QR-kód beolvasása a számítógépről No comment provided by engineer. @@ -6170,7 +6781,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Scan security code from your contact's app. - Biztonsági kód beolvasása az ismerősének alkalmazásából. + Biztonsági kód beolvasása a partnere alkalmazásából. No comment provided by engineer. @@ -6185,7 +6796,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Search bar accepts invitation links. - A keresősáv elfogadja a meghívó-hivatkozásokat. + A keresősáv elfogadja a meghívási hivatkozásokat. No comment provided by engineer. @@ -6195,12 +6806,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Secondary - Másodlagos + Másodlagos szín No comment provided by engineer. Secure queue - Biztonságos sorbaállítás + Biztonságos sorba állítás server test step @@ -6220,22 +6831,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Select - Kiválasztás + Kijelölés chat item action Select chat profile - Csevegési profil kiválasztása + Csevegési profil kijelölése No comment provided by engineer. Selected %lld - %lld kiválasztva + %lld kijelölve No comment provided by engineer. Selected chat preferences prohibit this message. - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -6245,17 +6856,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Self-destruct passcode - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód No comment provided by engineer. Self-destruct passcode changed! - Az önmegsemmisítési jelkód megváltozott! + Az önmegsemmisítő-jelkód módosult! No comment provided by engineer. Self-destruct passcode enabled! - Az önmegsemmisítési jelkód engedélyezve! + Az önmegsemmisítő-jelkód engedélyezve! No comment provided by engineer. @@ -6265,7 +6876,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send a live message - it will update for the recipient(s) as you type it - Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja + Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet No comment provided by engineer. @@ -6290,7 +6901,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send link previews - Hivatkozás előnézetek küldése + Hivatkozás előnézete No comment provided by engineer. @@ -6305,12 +6916,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send messages directly when IP address is protected and your or destination server does not support private routing. - Közvetlen üzenetküldés, ha az IP-cím védett és az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. No comment provided by engineer. Send messages directly when your or destination server does not support private routing. - Közvetlen üzenetküldés, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. No comment provided by engineer. @@ -6318,9 +6929,14 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Értesítések küldése No comment provided by engineer. + + Send private reports + Privát jelentések küldése + No comment provided by engineer. + Send questions and ideas - Ötletek és kérdések beküldése + Ötletek és javaslatok No comment provided by engineer. @@ -6330,12 +6946,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send them from gallery or custom keyboards. - Küldje el őket galériából vagy egyedi billentyűzetekről. + Küldje el őket a galériából vagy az egyéni billentyűzetekről. No comment provided by engineer. Send up to 100 last messages to new members. - Az utolsó 100 üzenet elküldése az új tagoknak. + Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára. No comment provided by engineer. @@ -6345,17 +6961,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sender may have deleted the connection request. - A küldő törölhette a kapcsolatkérést. + A küldője törölhette a meghívási kérést. No comment provided by engineer. Sending delivery receipts will be enabled for all contacts in all visible chat profiles. - A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára. + A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. No comment provided by engineer. Sending delivery receipts will be enabled for all contacts. - A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül. + A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. No comment provided by engineer. @@ -6365,7 +6981,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sending receipts is disabled for %lld contacts - A kézbesítési jelentések le vannak tiltva %lld ismerősnél + A kézbesítési jelentések le vannak tiltva %lld partnernél No comment provided by engineer. @@ -6375,7 +6991,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sending receipts is enabled for %lld contacts - A kézbesítési jelentések engedélyezve vannak %lld ismerősnél + A kézbesítési jelentések engedélyezve vannak %lld partnernél No comment provided by engineer. @@ -6385,17 +7001,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sending via - Küldés ezen keresztül + Küldés a következőn keresztül: No comment provided by engineer. Sent at - Elküldve ekkor: + Elküldve No comment provided by engineer. Sent at: %@ - Elküldve ekkor: %@ + Elküldve: %@ copied message info @@ -6420,7 +7036,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sent messages will be deleted after set time. - Az elküldött üzenetek törlésre kerülnek a beállított idő után. + Az elküldött üzenetek törölve lesznek a beállított idő után. No comment provided by engineer. @@ -6435,7 +7051,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sent via proxy - Proxyn keresztül küldve + Proxyn keresztül küldött No comment provided by engineer. @@ -6465,27 +7081,27 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Server operator changed. - A kiszolgáló üzemeltetője megváltozott. + A kiszolgáló üzemeltetője módosult. alert title Server operators - Kiszolgáló-üzemeltetők + Kiszolgálóüzemeltetők No comment provided by engineer. Server protocol changed. - A kiszolgáló-protokoll megváltozott. + A kiszolgáló-protokoll módosult. alert title Server requires authorization to create queues, check password - A kiszolgálónak engedélyre van szüksége a sorbaállítás létrehozásához, ellenőrizze jelszavát + A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát server test error Server requires authorization to upload, check password - A kiszolgálónak engedélyre van szüksége a várólisták feltöltéséhez, ellenőrizze jelszavát + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát server test error @@ -6520,7 +7136,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Servers statistics will be reset - this cannot be undone! - A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza! + A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -6533,9 +7149,14 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Beállítva 1 nap No comment provided by engineer. + + Set chat name… + Csevegés nevének beállítása… + No comment provided by engineer. + Set contact name… - Ismerős nevének beállítása… + Partner nevének beállítása… No comment provided by engineer. @@ -6550,7 +7171,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Set it instead of system authentication. - Rendszerhitelesítés helyetti beállítás. + Beállítás a rendszer-hitelesítés helyett. + No comment provided by engineer. + + + Set member admission + Tagbefogadás beállítása + No comment provided by engineer. + + + Set message expiration in chats. + Üzenetek eltűnési idejének módosítása a csevegésekben. No comment provided by engineer. @@ -6570,7 +7201,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Set the message shown to new members! - Megjelenő üzenet beállítása az új tagok számára! + Megjelenítendő üzenet beállítása az új tagok számára! No comment provided by engineer. @@ -6585,28 +7216,28 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Settings were changed. - A beállítások megváltoztak. + A beállítások módosultak. alert message Shape profile images - Profilkép alakzat + Profilkép alakzata No comment provided by engineer. Share Megosztás alert action - chat item action +chat item action Share 1-time link - Egyszer használható hivatkozás megosztása + Egyszer használható meghívó megosztása No comment provided by engineer. Share 1-time link with a friend - Egyszer használható meghívó-hivatkozás megosztása egy baráttal + Egyszer használható meghívó megosztása egy baráttal No comment provided by engineer. @@ -6626,7 +7257,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Share address with contacts? - Megosztja a címet az ismerőseivel? + Megosztja a címet a partnereivel? alert title @@ -6636,7 +7267,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Share link - Hivatkozás megosztása + Megosztás No comment provided by engineer. @@ -6646,17 +7277,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Share this 1-time invite link - Egyszer használható meghívó-hivatkozás megosztása + Ennek az egyszer használható meghívónak a megosztása No comment provided by engineer. Share to SimpleX - Megosztás a SimpleX-ben + Megosztás a SimpleXben No comment provided by engineer. Share with contacts - Megosztás az ismerősökkel + Megosztás a partnerekkel + No comment provided by engineer. + + + Short link + Rövid hivatkozás No comment provided by engineer. @@ -6676,12 +7312,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Show last messages - Szobák utolsó üzeneteinek megjelenítése a listanézetben + Legutóbbi üzenet előnézetének megjelenítése No comment provided by engineer. Show message status - Üzenetállapot megjelenítése + Üzenet állapotának megjelenítése No comment provided by engineer. @@ -6701,7 +7337,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Show: - Megjelenítés: + Megjelenítve: No comment provided by engineer. @@ -6751,14 +7387,19 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SimpleX address and 1-time links are safe to share via any messenger. - A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. + A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül. No comment provided by engineer. SimpleX address or 1-time link? - SimpleX-cím vagy egyszer használható meghívó-hivatkozás? + SimpleX-cím vagy egyszer használható meghívó? No comment provided by engineer. + + SimpleX channel link + SimpleX-csatornahivatkozás + simplex link type + SimpleX contact address SimpleX kapcsolattartási cím @@ -6781,7 +7422,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SimpleX links are prohibited. - A SimpleX-hivatkozások küldése le van tiltva ebben a csoportban. + A SimpleX-hivatkozások küldése le van tiltva. No comment provided by engineer. @@ -6791,12 +7432,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SimpleX one-time invitation - Egyszer használható SimpleX-meghívó-hivatkozás + Egyszer használható SimpleX-meghívó simplex link type SimpleX protocols reviewed by Trail of Bits. - A SimpleX Chat biztonsága a Trail of Bits által lett felülvizsgálva. + A SimpleX-protokollokat a Trail of Bits auditálta. No comment provided by engineer. @@ -6836,7 +7477,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Some file(s) were not exported: - Néhány fájl nem került exportálásra: + Néhány fájl nem lett exportálva: No comment provided by engineer. @@ -6861,6 +7502,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Valaki notification title + + Spam + Kéretlen tartalom + blocking reason +report reason + Square, circle, or anything in between. Négyzet, kör vagy bármi a kettő között. @@ -6873,7 +7520,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Start chat? - Csevegés indítása? + Elindítja a csevegést? No comment provided by engineer. @@ -6883,7 +7530,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Starting from %@. - Kezdve ettől %@. + Statisztikagyűjtés kezdete: %@. No comment provided by engineer. @@ -6913,7 +7560,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Stop chat? - Csevegési szolgáltatás megállítása? + Megállítja a csevegést? No comment provided by engineer. @@ -6923,12 +7570,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Stop receiving file? - Fájlfogadás megállítása? + Megállítja a fájlfogadást? No comment provided by engineer. Stop sending file? - Fájlküldés megállítása? + Megállítja a fájlküldést? No comment provided by engineer. @@ -6938,7 +7585,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Stop sharing address? - Címmegosztás megállítása? + Megállítja a címmegosztást? alert title @@ -6946,6 +7593,11 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csevegés megállítása folyamatban No comment provided by engineer. + + Storage + Tárhely + No comment provided by engineer. + Strong Erős @@ -6983,7 +7635,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Switch chat profile for 1-time invitations. - Csevegési profilváltás az egyszer használható meghívó-hivatkozásokhoz. + Csevegési profilváltás az egyszer használható meghívókhoz. No comment provided by engineer. @@ -6993,17 +7645,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. System authentication - Rendszerhitelesítés + Rendszer-hitelesítés No comment provided by engineer. TCP connection - TCP kapcsolat + TCP-kapcsolat No comment provided by engineer. TCP connection timeout - TCP kapcsolat időtúllépése + TCP-kapcsolat időtúllépése + No comment provided by engineer. + + + TCP port for messaging + TCP-port az üzenetváltáshoz No comment provided by engineer. @@ -7058,7 +7715,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Tap to join incognito - Koppintson ide az inkognitóban való csatlakozáshoz + Koppintson ide az inkognitóban való kapcsolódáshoz No comment provided by engineer. @@ -7068,19 +7725,24 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Tap to scan - Koppintson ide a QR-kód beolvasáshoz + Koppintson ide a QR-kód beolvasásához No comment provided by engineer. Temporary file error - Ideiglenesfájl-hiba - No comment provided by engineer. + Ideiglenes fájlhiba + file error alert title Test failed at step %@. - A teszt sikertelen volt a(z) %@ lépésnél. + A teszt a(z) %@ lépésnél sikertelen volt. server test failure + + Test notifications + Értesítések tesztelése + No comment provided by engineer. + Test server Kiszolgáló tesztelése @@ -7103,29 +7765,29 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! - Köszönet a felhasználóknak – [hozzájárulás a Weblate-en keresztül](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Köszönet a felhasználóknak [a Weblate-en való közreműködésért](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. Thanks to the users – contribute via Weblate! - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! No comment provided by engineer. The ID of the next message is incorrect (less or equal to the previous). It can happen because of some bug or when the connection is compromised. - A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel). -Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. + A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel). +Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. No comment provided by engineer. The app can notify you when you receive messages or contact requests - please open settings to enable. - Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – beállítások megnyitása az engedélyezéshez. + Az alkalmazás értesíteni fogja, amikor üzeneteket vagy meghívási kéréseket kap – ezt a beállítások menüben engedélyezheti. No comment provided by engineer. The app protects your privacy by using different operators in each conversation. - Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőket használ. + Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. No comment provided by engineer. @@ -7135,7 +7797,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The attempt to change database passphrase was not completed. - Az adatbázis jelmondatának megváltoztatására tett kísérlet nem fejeződött be. + Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. No comment provided by engineer. @@ -7145,7 +7807,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The connection reached the limit of undelivered messages, your contact may be offline. - A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van. + A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van. No comment provided by engineer. @@ -7155,7 +7817,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The contact you shared this link with will NOT be able to connect! - Ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! + A partnere, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! No comment provided by engineer. @@ -7170,17 +7832,17 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The future of messaging - A privát üzenetküldés következő generációja + Az üzenetváltás jövője No comment provided by engineer. The hash of the previous message is different. - Az előző üzenet hasító értéke különbözik. + Az előző üzenet hasítóértéke különbözik. No comment provided by engineer. The message will be deleted for all members. - Az üzenet az összes tag számára törlésre kerül. + Az üzenet az összes tag számára törölve lesz. No comment provided by engineer. @@ -7190,7 +7852,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The messages will be deleted for all members. - Az üzenetek az összes tag számára törlésre kerülnek. + Az üzenetek az összes tag számára törölve lesznek. No comment provided by engineer. @@ -7200,22 +7862,12 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The old database was not removed during the migration, it can be deleted. - A régi adatbázis nem került eltávolításra az átköltöztetéskor, így törölhető. - No comment provided by engineer. - - - The profile is only shared with your contacts. - A profilja csak az ismerőseivel kerül megosztásra. + A régi adatbázis nem lett eltávolítva az átköltöztetéskor, ezért törölhető. No comment provided by engineer. The same conditions will apply to operator **%@**. - Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**. - No comment provided by engineer. - - - The same conditions will apply to operator(s): **%@**. - Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k)re is: **%@**. + Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**. No comment provided by engineer. @@ -7235,12 +7887,12 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The servers for new connections of your current chat profile **%@**. - A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói **%@**. + A jelenlegi **%@** nevű csevegési profiljához tartozó új kapcsolatok kiszolgálói. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - Az Ön jelenlegi **%@** nevű csevegőprofiljához tartozó új fájlok kiszolgálói. + A jelenlegi **%@** nevű csevegési profiljához tartozó új fájlok kiszolgálói. No comment provided by engineer. @@ -7250,7 +7902,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The uploaded database archive will be permanently removed from the servers. - A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról. + A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. No comment provided by engineer. @@ -7265,27 +7917,32 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. These settings are for your current profile **%@**. - Ezek a beállítások csak a jelenlegi (**%@**) profiljára vonatkoznak. + Ezek a beállítások csak a jelenlegi **%@** nevű csevegési profiljára vonatkoznak. No comment provided by engineer. They can be overridden in contact and group settings. - Ezek felülbírálhatók az ismerős- és csoportbeállításokban. + Ezek felülbírálhatók a partner- és csoportbeállításokban. No comment provided by engineer. This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerül. Az alacsony felbontású képek viszont megmaradnak. + Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai visszafordíthatatlanul törlésre kerülnek. + Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek. No comment provided by engineer. @@ -7295,7 +7952,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This chat is protected by quantum resistant end-to-end encryption. - Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett. + Ez a csevegés végpontok közötti kvantumbiztos titkosítással védett. E2EE info chat item @@ -7305,12 +7962,12 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This display name is invalid. Please choose another name. - Ez a megjelenített név érvénytelen. Válasszon egy másik nevet. + Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet. No comment provided by engineer. This group has over %lld members, delivery receipts are not sent. - Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem kerülnek elküldésre. + Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem lesznek elküldve. No comment provided by engineer. @@ -7320,12 +7977,17 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This is your own SimpleX address! - Ez az Ön SimpleX-címe! + Ez a saját SimpleX-címe! No comment provided by engineer. This is your own one-time link! - Ez az Ön egyszer használható meghívó-hivatkozása! + Ez a saját egyszer használható meghívója! + No comment provided by engineer. + + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől. No comment provided by engineer. @@ -7333,9 +7995,14 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. No comment provided by engineer. + + This message was deleted or not received yet. + Ez az üzenet törölve lett vagy még nem érkezett meg. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. - Ez a beállítás csak a jelenlegi (**%@**) profiljában lévő üzenetekre vonatkozik. + Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegési profiljában lévő üzenetekre vonatkozik. No comment provided by engineer. @@ -7350,7 +8017,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. To connect, your contact can scan QR code or use the link in the app. - A kapcsolódáshoz az ismerőse beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. + A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. No comment provided by engineer. @@ -7365,7 +8032,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. To protect against your link being replaced, you can compare contact security codes. - A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat az ismerősével. + A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével. No comment provided by engineer. @@ -7387,7 +8054,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Az adatvédelem érdekében (a más csevegési platformokon megszokott felhasználó-azonosítók helyett) a SimpleX csak az üzenetek sorbaállításához használ azonosítókat, az összes ismerőséhez különbözőt. + Adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. No comment provided by engineer. @@ -7412,7 +8079,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Rejtett profilja megjelenítéséhez írja be a teljes jelszavát a keresőmezőbe a **Csevegési profilok** menüben. + Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben. No comment provided by engineer. @@ -7432,19 +8099,24 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To verify end-to-end encryption with your contact compare (or scan) the code on your devices. - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal. + A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. No comment provided by engineer. Toggle chat list: - Csevegőlista átváltása: + Csevegési lista átváltása: No comment provided by engineer. Toggle incognito when connecting. - Inkognitómód használata kapcsolódáskor. + Inkognitóra váltás kapcsolódáskor. No comment provided by engineer. + + Token status: %@. + Token állapota: %@. + token status + Toolbar opacity Eszköztár átlátszatlansága @@ -7457,7 +8129,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Transport isolation - Átvitel-izoláció módja + Átvitelelkülönítés No comment provided by engineer. @@ -7467,12 +8139,12 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Trying to connect to the server used to receive messages from this contact (error: %@). - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %@). + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@). No comment provided by engineer. Trying to connect to the server used to receive messages from this contact. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. No comment provided by engineer. @@ -7502,7 +8174,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Unblock for all - Letiltás feloldása az összes tag számára + Feloldás No comment provided by engineer. @@ -7517,7 +8189,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Unblock member? - Tag feloldása? + Feloldja a tag letiltását? No comment provided by engineer. @@ -7583,8 +8255,8 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. - Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát. -A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. + Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát. +A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. No comment provided by engineer. @@ -7594,7 +8266,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Unlink desktop? - Számítógép szétkapcsolása? + Leválasztja a számítógépet? No comment provided by engineer. @@ -7610,16 +8282,21 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Unmute Némítás megszüntetése - swipe action + notification label action Unread Olvasatlan swipe action + + Unsupported connection link + Nem támogatott kapcsolattartási hivatkozás + No comment provided by engineer. + Up to 100 last messages are sent to new members. - Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. No comment provided by engineer. @@ -7629,17 +8306,22 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Update database passphrase - Adatbázis-jelmondat megváltoztatása + Az adatbázis jelmondatának módosítása No comment provided by engineer. Update network settings? - Hálózati beállítások megváltoztatása? + Módosítja a hálózati beállításokat? No comment provided by engineer. Update settings? - Beállítások frissítése? + Frissíti a beállításokat? + No comment provided by engineer. + + + Updated conditions + Frissített feltételek No comment provided by engineer. @@ -7694,7 +8376,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Use SOCKS proxy - SOCKS proxy használata + SOCKS-proxy használata No comment provided by engineer. @@ -7702,9 +8384,19 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc SimpleX Chat-kiszolgálók használata? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + A következő TCP-port használata, amikor nincs port megadva: %@. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. + No comment provided by engineer. + Use chat - Csevegés használata + SimpleX Chat használata No comment provided by engineer. @@ -7749,7 +8441,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Use private routing with unknown servers when IP address is not protected. - Használjon privát útválasztást ismeretlen kiszolgálókkal, ha az IP-cím nem védett. + Használjon privát útválasztást az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. No comment provided by engineer. @@ -7767,6 +8459,11 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Kiszolgálók használata No comment provided by engineer. + + Use short links (BETA) + Rövid hivatkozások használata (béta) + No comment provided by engineer. + Use the app while in the call. Használja az alkalmazást hívás közben. @@ -7777,9 +8474,14 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Használja az alkalmazást egy kézzel. No comment provided by engineer. + + Use web port + Webport használata + No comment provided by engineer. + User selection - Felhasználó kiválasztása + Felhasználó kijelölése No comment provided by engineer. @@ -7834,7 +8536,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Via secure quantum resistant protocol. - Biztonságos kvantumrezisztens-protokollon keresztül. + Biztonságos kvantumbiztos protokollon keresztül. No comment provided by engineer. @@ -7854,7 +8556,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Videos and files up to 1gb - Videók és fájlok 1Gb méretig + Videók és fájlok legfeljebb 1GB méretig No comment provided by engineer. @@ -7889,7 +8591,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Voice messages are prohibited. - A hangüzenetek küldése le van tiltva ebben a csoportban. + A hangüzenetek küldése le van tiltva. No comment provided by engineer. @@ -7899,7 +8601,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Voice messages prohibited! - A hangüzenetek le vannak tilva! + A hangüzenetek le vannak tiltva! No comment provided by engineer. @@ -7929,7 +8631,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Wallpaper accent - Háttérkép kiemelés + Háttérkép kiemelőszíne No comment provided by engineer. @@ -7939,7 +8641,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Warning: starting chat on multiple devices is not supported and will cause message delivery failures - Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, továbbá üzenetkézbesítési hibákat okozhat + Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, mert üzenetkézbesítési hibákat okoz No comment provided by engineer. @@ -7954,7 +8656,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Welcome %@! - Üdvözöllek %@! + Üdvözöljük %@! No comment provided by engineer. @@ -7984,7 +8686,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc When more than one operator is enabled, none of them has metadata to learn who communicates with whom. - Amikor egynél több hálózati üzemeltető van engedélyezve, egyikük sem rendelkezik olyan metaadatokkal ahhoz, hogy felderítse, ki kommunikál kivel. + Amikor egynél több üzemeltető van engedélyezve, akkor egyik sem rendelkezik olyan metaadatokkal, amelyekből megtudható, hogy ki kivel kommunikál. No comment provided by engineer. @@ -8024,32 +8726,32 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Without Tor or VPN, your IP address will be visible to file servers. - Tor vagy VPN nélkül az IP-címe látható lesz a fájlkiszolgálók számára. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára. No comment provided by engineer. Without Tor or VPN, your IP address will be visible to these XFTP relays: %@. - Tor vagy VPN nélkül az IP-címe látható lesz a következő XFTP-közvetítő-kiszolgálók számára: %@. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára: %@. alert message Wrong database passphrase - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat No comment provided by engineer. Wrong key or unknown connection - most likely this connection is deleted. - Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött. + Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. snd error text Wrong key or unknown file chunk address - most likely file is deleted. - Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött. + Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. file error text Wrong passphrase! - Hibás jelmondat! + Érvénytelen jelmondat! No comment provided by engineer. @@ -8074,12 +8776,12 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You already have a chat profile with the same display name. Please choose another name. - Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet. + Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. No comment provided by engineer. You are already connected to %@. - Ön már kapcsolódva van ehhez: %@. + Ön már kapcsolódott a következőhöz: %@. No comment provided by engineer. @@ -8089,12 +8791,12 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already connecting to %@. - Már folyamatban van a kapcsolódás ehhez: %@. + A kapcsolódás már folyamatban van a következőhöz: %@. No comment provided by engineer. You are already connecting via this one-time link! - A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül! + A kapcsolódás már folyamatban van ezen az egyszer használható meghívón keresztül! No comment provided by engineer. @@ -8120,18 +8822,18 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already joining the group! Repeat join request? - Csatlakozás folyamatban! -Csatlakozáskérés megismétlése? + A csatlakozás már folyamatban van a csoporthoz! +Megismétli a meghívási kérést? No comment provided by engineer. You are connected to the server used to receive messages from this contact. - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. No comment provided by engineer. You are invited to group - Meghívást kapott a csoportba + Ön meghívást kapott a csoportba No comment provided by engineer. @@ -8149,11 +8851,6 @@ Csatlakozáskérés megismétlése? Ezt a „Megjelenés” menüben módosíthatja. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Az üzemeltetőket a „Hálózat és kiszolgálók” beállításaban konfigurálhatja. - No comment provided by engineer. - You can configure servers via settings. A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. @@ -8171,7 +8868,7 @@ Csatlakozáskérés megismétlése? You can enable them later via app Privacy & Security settings. - Később engedélyezheti őket az alkalmazás „Adatvédelem és biztonság” menüjében. + Később engedélyezheti őket az „Adatvédelem és biztonság” menüben. No comment provided by engineer. @@ -8181,12 +8878,12 @@ Csatlakozáskérés megismétlése? You can hide or mute a user profile - swipe it to the right. - Elrejtheti vagy lenémíthatja a felhasználó -profiljait - csúsztassa jobbra a profilt. + Elrejtheti vagy lenémíthatja a felhasználó -profiljait – csúsztassa jobbra a profilt. No comment provided by engineer. You can make it visible to your SimpleX contacts via Settings. - Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”. + Láthatóvá teheti a SimpleXbeli partnerei számára a „Beállításokban”. No comment provided by engineer. @@ -8196,12 +8893,12 @@ Csatlakozáskérés megismétlése? You can send messages to %@ from Archived contacts. - Az „Archivált ismerősökből” továbbra is küldhet üzeneteket neki: %@. + Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %@. No comment provided by engineer. You can set connection name, to remember who the link was shared with. - Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. + Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. No comment provided by engineer. @@ -8211,12 +8908,12 @@ Csatlakozáskérés megismétlése? You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. - Megoszthat egy hivatkozást vagy QR-kódot - így bárki csatlakozhat a csoporthoz. Ha a csoport később törlésre kerül, akkor nem fogja elveszíteni annak tagjait. + Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoportot Ön később törli, akkor nem fogja elveszíteni annak tagjait. No comment provided by engineer. You can share this address with your contacts to let them connect with **%@**. - Megoszthatja ezt a címet az ismerőseivel, hogy kapcsolatba léphessenek Önnel a(z) **%@** nevű profilján keresztül. + Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: **%@**. No comment provided by engineer. @@ -8226,7 +8923,7 @@ Csatlakozáskérés megismétlése? You can still view conversation with %@ in the list of chats. - A(z) %@ nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. + A(z) %@ nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. No comment provided by engineer. @@ -8241,7 +8938,12 @@ Csatlakozáskérés megismétlése? You can view invitation link again in connection details. - A meghívó-hivatkozást újra megtekintheti a kapcsolat részleteinél. + A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél. + alert message + + + You can view your reports in Chat with admins. + A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben. alert message @@ -8261,34 +8963,34 @@ Csatlakozáskérés megismétlése? You have already requested connection via this address! - Már küldött egy kapcsolatkérést ezen a címen keresztül! + Már küldött egy meghívási kérést ezen a címen keresztül! No comment provided by engineer. You have already requested connection! Repeat connection request? - Már küldött egy kapcsolódási kérelmet! -Kapcsolatkérés megismétlése? + Ön már küldött egy meghívási kérést! +Megismétli a meghívási kérést? No comment provided by engineer. You have to enter passphrase every time the app starts - it is not stored on the device. - A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra. + A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. No comment provided by engineer. You invited a contact - Meghívta egy ismerősét + Ön meghívta egy partnerét No comment provided by engineer. You joined this group - Csatlakozott ehhez a csoporthoz + Ön csatlakozott ehhez a csoporthoz No comment provided by engineer. You joined this group. Connecting to inviting group member. - Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. + Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. No comment provided by engineer. @@ -8303,17 +9005,17 @@ Kapcsolatkérés megismétlése? You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. - A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől. + A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. No comment provided by engineer. You need to allow your contact to call to be able to call them. - Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást. + Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást. No comment provided by engineer. You need to allow your contact to send voice messages to be able to send them. - Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak. + Engedélyeznie kell a hangüzenetek küldését a partnere számára, hogy hangüzeneteket küldhessenek egymásnak. No comment provided by engineer. @@ -8326,6 +9028,11 @@ Kapcsolatkérés megismétlése? Csoportmeghívó elküldve No comment provided by engineer. + + You should receive notifications. + Ön megkapja az értesítéseket. + token info + You will be connected to group when the group host's device is online, please wait or check later! Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! @@ -8338,17 +9045,17 @@ Kapcsolatkérés megismétlése? You will be connected when your connection request is accepted, please wait or check later! - Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, ha a meghívási kérése el lesz fogadva, várjon, vagy ellenőrizze később! No comment provided by engineer. You will be connected when your contact's device is online, please wait or check later! - Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. You will be required to authenticate when you start or resume the app after 30 seconds in background. - Az alkalmazás indításakor, vagy 30 másodpercnyi háttérben töltött idő után az alkalmazáshoz visszatérve hitelesítés szükséges. + Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. No comment provided by engineer. @@ -8373,17 +9080,17 @@ Kapcsolatkérés megismétlése? You won't lose your contacts if you later delete your address. - Nem veszíti el az ismerőseit, ha később törli a címét. + Nem veszíti el a partnereit, ha később törli a címét. No comment provided by engineer. You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile - Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban + Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban No comment provided by engineer. You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed - Inkognitóprofilt használ ehhez a csoporthoz - fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva + Inkognitóprofilt használ ehhez a csoporthoz – fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva No comment provided by engineer. @@ -8391,11 +9098,6 @@ Kapcsolatkérés megismétlése? Saját ICE-kiszolgálók No comment provided by engineer. - - Your SMP servers - Saját SMP-kiszolgálók - No comment provided by engineer. - Your SimpleX address Profil SimpleX-címe @@ -8418,7 +9120,7 @@ Kapcsolatkérés megismétlése? Your chat preferences - Csevegési beállítások + Az Ön csevegési beállításai alert title @@ -8433,17 +9135,17 @@ Kapcsolatkérés megismétlése? Your contact sent a file that is larger than currently supported maximum size (%@). - Az ismerőse olyan fájlt küldött, amely meghaladja a jelenleg támogatott maximális méretet (%@). + A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött. No comment provided by engineer. Your contacts can allow full message deletion. - Az ismerősei engedélyezhetik a teljes üzenet törlést. + A partnerei engedélyezhetik a teljes üzenet törlését. No comment provided by engineer. Your contacts will remain connected. - Az ismerősei továbbra is kapcsolódva maradnak. + A partnerei továbbra is kapcsolódva maradnak. No comment provided by engineer. @@ -8453,7 +9155,7 @@ Kapcsolatkérés megismétlése? Your current chat database will be DELETED and REPLACED with the imported one. - A jelenlegi csevegési adatbázis TÖRLŐDNI FOG, és a HELYÉRE az importált adatbázis kerül. + A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra. No comment provided by engineer. @@ -8478,24 +9180,24 @@ Kapcsolatkérés megismétlése? Your profile **%@** will be shared. - A(z) **%@** nevű profilja megosztásra fog kerülni. + A(z) **%@** nevű profilja meg lesz osztva. + No comment provided by engineer. + + + Your profile is stored on your device and only shared with your contacts. + A profilja az eszközén van tárolva és csak a partnereivel van megosztva. No comment provided by engineer. Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. - A profilja az eszközön van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját. + A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. No comment provided by engineer. Your profile was changed. If you save it, the updated profile will be sent to all your contacts. - A profilja megváltozott. Ha elmenti, a frissített profil elküldésre kerül az összes ismerősének. + A profilja módosult. Ha elmenti, a profilfrissítés el lesz küldve a partnerei számára. alert message - - Your profile, contacts and delivered messages are stored on your device. - A profilja, az ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra. - No comment provided by engineer. - Your random profile Véletlenszerű profil @@ -8508,7 +9210,7 @@ Kapcsolatkérés megismétlése? Your servers - Az Ön kiszolgálói + Saját kiszolgálók No comment provided by engineer. @@ -8518,7 +9220,7 @@ Kapcsolatkérés megismétlése? [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Hozzájárulás](https://github.com/simplex-chat/simplex-chat#contribute) + [Közreműködés](https://github.com/simplex-chat/simplex-chat#contribute) No comment provided by engineer. @@ -8546,9 +9248,14 @@ Kapcsolatkérés megismétlése? gombra fent, majd válassza ki: No comment provided by engineer. + + accepted %@ + befogadta őt: %@ + rcv group event chat item + accepted call - elfogadott hívás + fogadott hívás call status @@ -8556,6 +9263,11 @@ Kapcsolatkérés megismétlése? elfogadott meghívó chat list item title + + accepted you + befogadta Önt + rcv group event chat item + admin adminisztrátor @@ -8576,6 +9288,11 @@ Kapcsolatkérés megismétlése? titkosítás elfogadása… chat item text + + all + összes + member criteria value + all members összes tag @@ -8591,6 +9308,11 @@ Kapcsolatkérés megismétlése? és további %lld esemény No comment provided by engineer. + + archived report + archivált jelentés + No comment provided by engineer. + attempts próbálkozások @@ -8613,7 +9335,7 @@ Kapcsolatkérés megismétlése? bad message hash - hibás az üzenet hasító értéke + érvénytelen az üzenet hasítóértéke integrity error chat item @@ -8623,13 +9345,14 @@ Kapcsolatkérés megismétlése? blocked %@ - letiltotta %@-t + letiltotta őt: %@ rcv group event chat item blocked by admin letiltva az adminisztrátor által - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8656,6 +9379,11 @@ Kapcsolatkérés megismétlése? hívás… call status + + can't send messages + nem lehet üzeneteket küldeni + No comment provided by engineer. + cancelled %@ %@ visszavonva @@ -8663,32 +9391,32 @@ Kapcsolatkérés megismétlése? changed address for you - cím megváltoztatva + módosította a címet az Ön számára chat item text changed role of %1$@ to %2$@ - %1$@ szerepkörét megváltoztatta erre: %2$@ + a következőre módosította %1$@ szerepkörét: „%2$@” rcv group event chat item changed your role to %@ - megváltoztatta az Ön szerepkörét erre: %@ + a következőre módosította az Ön szerepkörét: „%@” rcv group event chat item changing address for %@… - cím megváltoztatása nála: %@… + cím módosítása %@ számára… chat item text changing address… - cím megváltoztatása… + cím módosítása… chat item text colored - színes + színezett No comment provided by engineer. @@ -8728,12 +9456,12 @@ Kapcsolatkérés megismétlése? connecting (introduced) - kapcsolódás (bejelentve) + kapcsolódás (bemutatkozva) No comment provided by engineer. connecting (introduction invitation) - kapcsolódás (bemutatkozó-meghívó) + kapcsolódás (bemutatkozó meghívó) No comment provided by engineer. @@ -8758,17 +9486,32 @@ Kapcsolatkérés megismétlése? contact %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét erre: %2$@ + %1$@ a következőre módosította a nevét: %2$@ profile update event chat item + + contact deleted + partner törölve + No comment provided by engineer. + + + contact disabled + partner letiltva + No comment provided by engineer. + contact has e2e encryption - az ismerősnél az e2e titkosítás elérhető + a partner e2e titkosítással rendelkezik No comment provided by engineer. contact has no e2e encryption - az ismerősnél az e2e titkosítás nem elérhető + a partner nem rendelkezik e2e titkosítással + No comment provided by engineer. + + + contact not ready + a kapcsolat nem áll készen No comment provided by engineer. @@ -8778,12 +9521,12 @@ Kapcsolatkérés megismétlése? custom - egyedi + egyéni dropdown time picker choice database version is newer than the app, but no down migration for: %@ - az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés ehhez: %@ + az adatbázis verziója újabb, mint az alkalmazásé, de a visszafelé történő átköltöztetés viszont nem lehetséges a következőhöz: %@ No comment provided by engineer. @@ -8799,7 +9542,8 @@ Kapcsolatkérés megismétlése? default (%@) alapértelmezett (%@) - pref value + delete after time +pref value default (no) @@ -8818,7 +9562,7 @@ Kapcsolatkérés megismétlése? deleted contact - törölt ismerős + törölt partner rcv direct event chat item @@ -8828,7 +9572,7 @@ Kapcsolatkérés megismétlése? different migration in the app/database: %@ / %@ - különböző átköltöztetések az alkalmazásban/adatbázisban: %@ / %@ + különböző átköltöztetés az alkalmazásban/adatbázisban: %@ / %@ No comment provided by engineer. @@ -8863,7 +9607,7 @@ Kapcsolatkérés megismétlése? enabled for contact - engedélyezve az ismerős számára + engedélyezve a partner számára enabled status @@ -8888,27 +9632,27 @@ Kapcsolatkérés megismétlése? encryption ok for %@ - titkosítás rendben vele: %@ + titkosítás rendben %@ számára chat item text encryption re-negotiation allowed - titkosítás újraegyeztetés engedélyezve + a titkosítás újraegyeztetése engedélyezve van chat item text encryption re-negotiation allowed for %@ - titkosítás újraegyeztetés engedélyezve vele: %@ + a titkosítás újraegyeztetése engedélyezve van %@ számára chat item text encryption re-negotiation required - titkosítás újraegyeztetés szükséges + a titkosítás újraegyeztetése szükséges chat item text encryption re-negotiation required for %@ - titkosítás újraegyeztetés szükséges %@ számára + a titkosítás újraegyeztetése szükséges %@ számára chat item text @@ -8926,21 +9670,11 @@ Kapcsolatkérés megismétlése? hiba No comment provided by engineer. - - event happened - esemény történt - No comment provided by engineer. - expired lejárt No comment provided by engineer. - - for better metadata privacy. - a metaadatok jobb védelme érdekében. - No comment provided by engineer. - forwarded továbbított @@ -8951,6 +9685,11 @@ Kapcsolatkérés megismétlése? a csoport törölve No comment provided by engineer. + + group is deleted + csoport törölve + No comment provided by engineer. + group profile updated csoportprofil frissítve @@ -8963,12 +9702,12 @@ Kapcsolatkérés megismétlése? iOS Keychain is used to securely store passphrase - it allows receiving push notifications. - Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását. No comment provided by engineer. iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Az iOS kulcstartó az alkalmazás újraindítása, vagy a jelmondat módosítása után a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását. No comment provided by engineer. @@ -8988,7 +9727,7 @@ Kapcsolatkérés megismétlése? incognito via one-time link - inkognitó egy egyszer használható meghívó-hivatkozáson keresztül + inkognitó egy egyszer használható meghívón keresztül chat list item description @@ -9033,12 +9772,12 @@ Kapcsolatkérés megismétlése? invited to connect - meghívta, hogy csatlakozzon + Függőben lévő meghívó chat list item title invited via your group link - meghíva az Ön csoporthivatkozásán keresztül + meghíva a saját csoporthivatkozásán keresztül rcv group event chat item @@ -9048,7 +9787,7 @@ Kapcsolatkérés megismétlése? join as %@ - csatlakozás mint: %@ + csatlakozás mint %@ No comment provided by engineer. @@ -9068,7 +9807,7 @@ Kapcsolatkérés megismétlése? member %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét erre: %2$@ + %1$@ a következőre módosította a nevét: %2$@ profile update event chat item @@ -9076,6 +9815,11 @@ Kapcsolatkérés megismétlése? kapcsolódott rcv group event chat item + + member has old version + a tag régi verziót használ + No comment provided by engineer. + message üzenet @@ -9106,20 +9850,20 @@ Kapcsolatkérés megismétlése? moderálva lett %@ által marked deleted chat item preview text + + moderator + moderátor + member role + months hónap time unit - - mute - némítás - No comment provided by engineer. - never soha - No comment provided by engineer. + delete after time new message @@ -9141,6 +9885,11 @@ Kapcsolatkérés megismétlése? nincs szöveg copied message info in history + + not synchronized + nincs szinkronizálva + No comment provided by engineer. + observer megfigyelő @@ -9150,8 +9899,9 @@ Kapcsolatkérés megismétlése? off kikapcsolva enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9160,7 +9910,7 @@ Kapcsolatkérés megismétlése? offered %1$@: %2$@ - ajánlotta %1$@: %2$@-kor + ajánlotta: %1$@, ekkor: %2$@ feature offered item @@ -9190,12 +9940,27 @@ Kapcsolatkérés megismétlése? peer-to-peer - ponttól-pontig + egyenrangú + No comment provided by engineer. + + + pending + függőben + No comment provided by engineer. + + + pending approval + jóváhagyásra vár + No comment provided by engineer. + + + pending review + függőben lévő áttekintés No comment provided by engineer. quantum resistant e2e encryption - végpontok közötti kvantumrezisztens titkosítás + végpontok közötti kvantumbiztos titkosítás chat item text @@ -9208,6 +9973,11 @@ Kapcsolatkérés megismétlése? visszaigazolás fogadása… No comment provided by engineer. + + rejected + elutasítva + No comment provided by engineer. + rejected call elutasított hívás @@ -9228,6 +9998,11 @@ Kapcsolatkérés megismétlése? eltávolította a kapcsolattartási címet profile update event chat item + + removed from group + eltávolítva a csoportból + No comment provided by engineer. + removed profile picture eltávolította a profilképét @@ -9238,11 +10013,26 @@ Kapcsolatkérés megismétlése? eltávolította Önt rcv group event chat item + + request to join rejected + csatlakozási kérelem elutasítva + No comment provided by engineer. + requested to connect - kérelmezve a kapcsolódáshoz + Függőben lévő meghívási kérelem chat list item title + + review + áttekintés + No comment provided by engineer. + + + reviewed by admins + áttekintve a moderátorok által + No comment provided by engineer. + saved mentett @@ -9275,7 +10065,7 @@ Kapcsolatkérés megismétlése? security code changed - a biztonsági kód megváltozott + a biztonsági kód módosult chat item text @@ -9287,14 +10077,14 @@ Kapcsolatkérés megismétlése? server queue info: %1$@ last received msg: %2$@ - a kiszolgáló üzenet-sorbaállítási információi: %1$@ + a kiszolgáló sorbaállítási információi: %1$@ utoljára fogadott üzenet: %2$@ queue info set new contact address - új kapcsolattartási cím beállítása + új kapcsolattartási címet állított be profile update event chat item @@ -9319,7 +10109,7 @@ utoljára fogadott üzenet: %2$@ this contact - ez az ismerős + ez a partner notification title @@ -9342,11 +10132,6 @@ utoljára fogadott üzenet: %2$@ ismeretlen állapot No comment provided by engineer. - - unmute - némítás megszüntetése - No comment provided by engineer. - unprotected nem védett @@ -9374,7 +10159,7 @@ utoljára fogadott üzenet: %2$@ via contact address link - kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási címhivatkozáson keresztül chat list item description @@ -9384,12 +10169,12 @@ utoljára fogadott üzenet: %2$@ via one-time link - egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívón keresztül chat list item description via relay - közvetítő-kiszolgálón keresztül + egy továbbítókiszolgálón keresztül No comment provided by engineer. @@ -9437,9 +10222,14 @@ utoljára fogadott üzenet: %2$@ Ön No comment provided by engineer. + + you accepted this member + Ön befogadta ezt a tagot + snd group event chat item + you are invited to group - meghívást kapott a csoportba + Ön meghívást kapott a csoportba No comment provided by engineer. @@ -9454,22 +10244,22 @@ utoljára fogadott üzenet: %2$@ you changed address - cím megváltoztatva + Ön módosította a címet chat item text you changed address for %@ - cím megváltoztatva nála: %@ + Ön módosította a címet %@ számára chat item text you changed role for yourself to %@ - saját szerepköre megváltozott erre: %@ + Ön a következőre módosította a saját szerepkörét: „%@” snd group event chat item you changed role of %1$@ to %2$@ - Ön megváltoztatta %1$@ szerepkörét erre: %@ + Ön a következőre módosította %1$@ szerepkörét: „%2$@” snd group event chat item @@ -9484,12 +10274,12 @@ utoljára fogadott üzenet: %2$@ you shared one-time link - Ön egy egyszer használható meghívó-hivatkozást osztott meg + Ön egy egyszer használható meghívót osztott meg chat list item description you shared one-time link incognito - Ön egy egyszer használható meghívó-hivatkozást osztott meg inkognitóban + Ön egy egyszer használható meghívót osztott meg inkognitóban chat list item description @@ -9511,7 +10301,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -9531,7 +10321,7 @@ utoljára fogadott üzenet: %2$@ SimpleX uses local network access to allow using user chat profile via desktop app on the same network. - A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegőprofil használatát számítógépen keresztül ugyanazon a hálózaton. + A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegési profil használatát számítógépen keresztül ugyanazon a hálózaton. Privacy - Local Network Usage Description @@ -9548,7 +10338,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -9570,7 +10360,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -9578,6 +10368,11 @@ utoljára fogadott üzenet: %2$@ %d új esemény notification body + + From %d chat(s) + %d csevegésből + notification body + From: %@ Tőle: %@ @@ -9593,16 +10388,11 @@ utoljára fogadott üzenet: %2$@ Új üzenetek notification - - New messages in %d chats - Új üzenetek %d csevegésben - notification body -
- +
@@ -9624,7 +10414,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -9634,7 +10424,7 @@ utoljára fogadott üzenet: %2$@ App is locked! - Az alkalmazás zárolva! + Az alkalmazás zárolva van! No comment provided by engineer. @@ -9659,7 +10449,7 @@ utoljára fogadott üzenet: %2$@ Currently maximum supported file size is %@. - Jelenleg a maximálisan támogatott fájlméret: %@. + Jelenleg támogatott legnagyobb fájl méret: %@. No comment provided by engineer. @@ -9679,12 +10469,12 @@ utoljára fogadott üzenet: %2$@ Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata eltér a kulcstartóban lévőtől. + Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. No comment provided by engineer. Database passphrase is required to open chat. - Adatbázis-jelmondat szükséges a csevegés megnyitásához. + A csevegés megnyitásához adja meg az adatbázis jelmondatát. No comment provided by engineer. @@ -9694,12 +10484,12 @@ utoljára fogadott üzenet: %2$@ Error preparing file - Hiba a fájl előkészítésekor + Hiba történt a fájl előkészítésekor No comment provided by engineer. Error preparing message - Hiba az üzenet előkészítésekor + Hiba történt az üzenet előkészítésekor No comment provided by engineer. @@ -9764,7 +10554,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -9784,7 +10574,7 @@ utoljára fogadott üzenet: %2$@ Slow network? - Lassú internetkapcsolat? + Lassú a hálózata? No comment provided by engineer. @@ -9804,7 +10594,7 @@ utoljára fogadott üzenet: %2$@ Wrong database passphrase - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/contents.json b/apps/ios/SimpleX Localizations/hu.xcloc/contents.json index 0b16198498..c07ec0f900 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/hu.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "hu", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index d785acda81..d3c2a139cc 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (può essere copiato) @@ -202,6 +190,11 @@ %d sec time interval + + %d seconds(s) + %d secondo/i + delete after time + %d skipped message(s) %d messaggio/i saltato/i @@ -272,11 +265,6 @@ %lld nuove lingue dell'interfaccia No comment provided by engineer. - - %lld second(s) - %lld secondo/i - No comment provided by engineer. - %lld seconds %lld secondi @@ -327,11 +315,6 @@ %u messaggi saltati. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nuovo) @@ -342,11 +325,6 @@ (questo dispositivo v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Aggiungi contatto**: per creare un nuovo link di invito. @@ -412,11 +390,6 @@ \*grassetto* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -453,11 +426,6 @@ - cronologia delle modifiche. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -471,7 +439,8 @@ 1 day 1 giorno - time interval + delete after time +time interval 1 hour @@ -486,12 +455,19 @@ 1 month 1 mese - time interval + delete after time +time interval 1 week 1 settimana - time interval + delete after time +time interval + + + 1 year + 1 anno + delete after time 1-time link @@ -518,11 +494,6 @@ 30 secondi No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -579,6 +550,7 @@ About operators + Info sugli operatori No comment provided by engineer. @@ -590,8 +562,18 @@ Accept Accetta accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + Accetta come membro + alert action + + + Accept as observer + Accetta come osservatore + alert action Accept conditions @@ -612,7 +594,12 @@ Accept incognito Accetta in incognito accept contact request via notification - swipe action +swipe action + + + Accept member + Accetta membro + alert title Accepted conditions @@ -629,6 +616,11 @@ Errori di riconoscimento No comment provided by engineer. + + Active + Attivo + token status text + Active connections Connessioni attive @@ -644,6 +636,11 @@ Aggiungi amici No comment provided by engineer. + + Add list + Aggiungi elenco + No comment provided by engineer. + Add profile Aggiungi profilo @@ -669,6 +666,11 @@ Aggiungi ad un altro dispositivo No comment provided by engineer. + + Add to list + Aggiungi ad un elenco + No comment provided by engineer. + Add welcome message Aggiungi messaggio di benvenuto @@ -744,6 +746,11 @@ Impostazioni avanzate No comment provided by engineer. + + All + Tutte + No comment provided by engineer. + All app data is deleted. Tutti i dati dell'app vengono eliminati. @@ -754,6 +761,11 @@ Tutte le chat e i messaggi verranno eliminati. Non è reversibile! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Tutte le chat verranno rimosse dall'elenco %@ e l'elenco eliminato. + alert message + All data is erased when it is entered. Tutti i dati vengono cancellati quando inserito. @@ -794,6 +806,16 @@ Tutti gli profili profile dropdown + + All reports will be archived for you. + Tutte le segnalazioni verranno archiviate per te. + No comment provided by engineer. + + + All servers + Tutti i server + No comment provided by engineer. + All your contacts will remain connected. Tutti i tuoi contatti resteranno connessi. @@ -869,6 +891,11 @@ Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore) No comment provided by engineer. + + Allow to report messsages to moderators. + Consenti di segnalare messaggi ai moderatori. + No comment provided by engineer. + Allow to send SimpleX links. Consenti di inviare link di SimpleX. @@ -949,6 +976,11 @@ Viene creato un profilo di chat vuoto con il nome scelto e l'app si apre come al solito. No comment provided by engineer. + + Another reason + Altro motivo + report reason + Answer call Rispondi alla chiamata @@ -974,6 +1006,11 @@ L'app cripta i nuovi file locali (eccetto i video). No comment provided by engineer. + + App group: + Gruppo app: + No comment provided by engineer. + App icon Icona app @@ -1019,6 +1056,21 @@ Applica a No comment provided by engineer. + + Archive + Archivia + No comment provided by engineer. + + + Archive %lld reports? + Archiviare %lld segnalazioni? + No comment provided by engineer. + + + Archive all reports? + Archiviare tutte le segnalazioni? + No comment provided by engineer. + Archive and upload Archivia e carica @@ -1029,6 +1081,21 @@ Archivia contatti per chattare più tardi. No comment provided by engineer. + + Archive report + Archivia la segnalazione + No comment provided by engineer. + + + Archive report? + Archiviare la segnalazione? + No comment provided by engineer. + + + Archive reports + Archivia segnalazioni + swipe action + Archived contacts Contatti archiviati @@ -1139,6 +1206,11 @@ Gruppi migliorati No comment provided by engineer. + + Better groups performance + Prestazioni dei gruppi migliorate + No comment provided by engineer. + Better message dates. Date dei messaggi migliorate. @@ -1159,6 +1231,11 @@ Notifiche migliorate No comment provided by engineer. + + Better privacy and security + Privacy e sicurezza migliori + No comment provided by engineer. + Better security ✅ Sicurezza migliorata ✅ @@ -1259,11 +1336,25 @@ Chat di lavoro No comment provided by engineer. + + Businesses + Lavorative + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Usando SimpleX Chat accetti di: +- inviare solo contenuto legale nei gruppi pubblici. +- rispettare gli altri utenti - niente spam. + No comment provided by engineer. + Call already ended! Chiamata già terminata! @@ -1313,7 +1404,7 @@ Cancel Annulla alert action - alert button +alert button Cancel migration @@ -1350,6 +1441,11 @@ Cambia No comment provided by engineer. + + Change automatic message deletion? + Cambiare l'eliminazione automatica dei messaggi? + alert title + Change chat profiles Modifica profili utente @@ -1399,7 +1495,7 @@ Change self-destruct passcode Cambia codice di autodistruzione authentication reason - set passcode view +set passcode view Chat @@ -1501,11 +1597,26 @@ La chat verrà eliminata solo per te, non è reversibile! No comment provided by engineer. + + Chat with admins + Chat con amministratori + chat toolbar + + + Chat with member + Chatta con il membro + No comment provided by engineer. + Chats Chat No comment provided by engineer. + + Chats with members + Chat con membri + No comment provided by engineer. + Check messages every 20 min. Controlla i messaggi ogni 20 min. @@ -1571,6 +1682,16 @@ Svuotare la conversazione? No comment provided by engineer. + + Clear group? + Svuotare il gruppo? + No comment provided by engineer. + + + Clear or delete group? + Svuotare o eliminare il gruppo? + No comment provided by engineer. + Clear private notes? Svuotare le note private? @@ -1591,6 +1712,11 @@ Modalità di colore No comment provided by engineer. + + Community guidelines violation + Violazione delle linee guida della comunità + report reason + Compare file Confronta file @@ -1626,16 +1752,6 @@ Condizioni d'uso No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Le condizioni verranno accettate per gli operatori attivati dopo 30 giorni. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Le condizioni verranno accettate per gli operatori: **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Le condizioni verranno accettate per gli operatori: **%@**. @@ -1656,6 +1772,11 @@ Configura server ICE No comment provided by engineer. + + Configure server operators + Configura gli operatori dei server + No comment provided by engineer. + Confirm Conferma @@ -1706,6 +1827,11 @@ Conferma caricamento No comment provided by engineer. + + Confirmed + Confermato + token status text + Connect Connetti @@ -1825,6 +1951,11 @@ Questo è il tuo link una tantum! Stato della connessione e dei server. No comment provided by engineer. + + Connection blocked + Connessione bloccata + No comment provided by engineer. + Connection error Errore di connessione @@ -1835,6 +1966,18 @@ Questo è il tuo link una tantum! Errore di connessione (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + La connessione è bloccata dall'operatore del server: +%@ + No comment provided by engineer. + + + Connection not ready. + Connessione non pronta. + No comment provided by engineer. + Connection notifications Notifiche di connessione @@ -1845,6 +1988,11 @@ Questo è il tuo link una tantum! Richiesta di connessione inviata! No comment provided by engineer. + + Connection requires encryption renegotiation. + La connessione richiede la rinegoziazione della crittografia. + No comment provided by engineer. + Connection security Sicurezza della connessione @@ -1925,6 +2073,11 @@ Questo è il tuo link una tantum! I contatti possono contrassegnare i messaggi per l'eliminazione; potrai vederli. No comment provided by engineer. + + Content violates conditions of use + Il contenuto viola le condizioni di utilizzo + blocking reason + Continue Continua @@ -2000,6 +2153,11 @@ Questo è il tuo link una tantum! Crea link No comment provided by engineer. + + Create list + Crea elenco + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Crea un nuovo profilo nell'[app desktop](https://simplex.chat/downloads/). 💻 @@ -2217,8 +2375,7 @@ Questo è il tuo link una tantum! Delete Elimina alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2260,6 +2417,11 @@ Questo è il tuo link una tantum! Elimina chat No comment provided by engineer. + + Delete chat messages from your device. + Elimina i messaggi di chat dal tuo dispositivo. + No comment provided by engineer. + Delete chat profile Elimina il profilo di chat @@ -2270,6 +2432,11 @@ Questo è il tuo link una tantum! Eliminare il profilo di chat? No comment provided by engineer. + + Delete chat with member? + Eliminare la chat con il membro? + alert title + Delete chat? Eliminare la chat? @@ -2350,6 +2517,11 @@ Questo è il tuo link una tantum! Eliminare il link? No comment provided by engineer. + + Delete list? + Eliminare l'elenco? + alert title + Delete member message? Eliminare il messaggio del membro? @@ -2363,7 +2535,7 @@ Questo è il tuo link una tantum! Delete messages Elimina messaggi - No comment provided by engineer. + alert button Delete messages after @@ -2400,6 +2572,11 @@ Questo è il tuo link una tantum! Elimina coda server test step + + Delete report + Elimina la segnalazione + No comment provided by engineer. + Delete up to 20 messages at once. Elimina fino a 20 messaggi contemporaneamente. @@ -2560,6 +2737,16 @@ Questo è il tuo link una tantum! Disattiva SimpleX Lock authentication reason + + Disable automatic message deletion? + Disattivare l'eliminazione automatica dei messaggi? + alert title + + + Disable delete messages + Disattiva eliminazione messaggi + alert button + Disable for all Disattiva per tutti @@ -2650,6 +2837,11 @@ Questo è il tuo link una tantum! Non usare credenziali con proxy. No comment provided by engineer. + + Documents: + Documenti: + No comment provided by engineer. + Don't create address Non creare un indirizzo @@ -2660,9 +2852,19 @@ Questo è il tuo link una tantum! Non attivare No comment provided by engineer. + + Don't miss important messages. + Non perdere messaggi importanti. + No comment provided by engineer. + Don't show again Non mostrare più + alert action + + + Done + Fatto No comment provided by engineer. @@ -2674,7 +2876,7 @@ Questo è il tuo link una tantum! Download Scarica alert button - chat item action +chat item action Download errors @@ -2751,9 +2953,9 @@ Questo è il tuo link una tantum! Attiva (mantieni sostituzioni) No comment provided by engineer. - - Enable Flux - Attiva Flux + + Enable Flux in Network & servers settings for better metadata privacy. + Attiva Flux nelle impostazioni "Rete e server" per una migliore privacy dei metadati. No comment provided by engineer. @@ -2769,7 +2971,7 @@ Questo è il tuo link una tantum! Enable automatic message deletion? Attivare l'eliminazione automatica dei messaggi? - No comment provided by engineer. + alert title Enable camera access @@ -2896,6 +3098,11 @@ Questo è il tuo link una tantum! Rinegoziazione crittografia fallita. No comment provided by engineer. + + Encryption renegotiation in progress. + Rinegoziazione della crittografia in corso. + No comment provided by engineer. + Enter Passcode Inserisci il codice di accesso @@ -2971,6 +3178,11 @@ Questo è il tuo link una tantum! Errore nell'accettazione della richiesta di contatto No comment provided by engineer. + + Error accepting member + Errore di accettazione del membro + alert title + Error adding member(s) Errore di aggiunta membro/i @@ -3006,6 +3218,11 @@ Questo è il tuo link una tantum! Errore nel passaggio a incognito! No comment provided by engineer. + + Error checking token status + Errore di controllo dello stato del token + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Errore di connessione al server di inoltro %@. Riprova più tardi. @@ -3026,6 +3243,11 @@ Questo è il tuo link una tantum! Errore nella creazione del link del gruppo No comment provided by engineer. + + Error creating list + Errore nella creazione dell'elenco + alert title + Error creating member contact Errore di creazione del contatto @@ -3041,6 +3263,11 @@ Questo è il tuo link una tantum! Errore nella creazione del profilo! No comment provided by engineer. + + Error creating report + Errore nella creazione del resoconto + No comment provided by engineer. + Error decrypting file Errore decifrando il file @@ -3051,6 +3278,11 @@ Questo è il tuo link una tantum! Errore nell'eliminazione del database della chat No comment provided by engineer. + + Error deleting chat with member + Errore di eliminazione della chat con il membro + alert title + Error deleting chat! Errore nell'eliminazione della chat! @@ -3151,10 +3383,20 @@ Questo è il tuo link una tantum! Errore di riconnessione ai server No comment provided by engineer. + + Error registering for notifications + Errore di registrazione per le notifiche + alert title + Error removing member Errore nella rimozione del membro - No comment provided by engineer. + alert title + + + Error reordering lists + Errore riordinando gli elenchi + alert title Error resetting statistics @@ -3166,6 +3408,11 @@ Questo è il tuo link una tantum! Errore nel salvataggio dei server ICE No comment provided by engineer. + + Error saving chat list + Errore nel salvataggio dell'elenco di chat + alert title + Error saving group profile Errore nel salvataggio del profilo del gruppo @@ -3246,6 +3493,11 @@ Questo è il tuo link una tantum! Errore nella sincronizzazione della connessione No comment provided by engineer. + + Error testing server connection + Errore provando la connessione al server + No comment provided by engineer. + Error updating group link Errore nell'aggiornamento del link del gruppo @@ -3289,7 +3541,9 @@ Questo è il tuo link una tantum! Error: %@ Errore: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3326,6 +3580,11 @@ Questo è il tuo link una tantum! Espandi chat item action + + Expired + Scaduto + token status text + Export database Esporta database @@ -3366,20 +3625,35 @@ Questo è il tuo link una tantum! Veloce e senza aspettare che il mittente sia in linea! No comment provided by engineer. + + Faster deletion of groups. + Eliminazione dei gruppi più veloce. + No comment provided by engineer. + Faster joining and more reliable messages. Ingresso più veloce e messaggi più affidabili. No comment provided by engineer. + + Faster sending messages. + Invio dei messaggi più veloce. + No comment provided by engineer. + Favorite Preferito swipe action + + Favorites + Preferite + No comment provided by engineer. + File error Errore del file - No comment provided by engineer. + file error alert title File errors: @@ -3388,6 +3662,13 @@ Questo è il tuo link una tantum! %@ alert message + + File is blocked by server operator: +%@. + Il file è bloccato dall'operatore del server: +%@. + file error text + File not found - most likely file was deleted or cancelled. File non trovato - probabilmente è stato eliminato o annullato. @@ -3513,6 +3794,11 @@ Questo è il tuo link una tantum! Correzione non supportata dal membro del gruppo No comment provided by engineer. + + For all moderators + Per tutti i moderatori + No comment provided by engineer. + For chat profile %@: Per il profilo di chat %@: @@ -3528,6 +3814,11 @@ Questo è il tuo link una tantum! Ad esempio, se il tuo contatto riceve messaggi tramite un server di SimpleX Chat, la tua app li consegnerà tramite un server Flux. No comment provided by engineer. + + For me + Per me + No comment provided by engineer. + For private routing Per l'instradamento privato @@ -3652,6 +3943,11 @@ Errore: %2$@ GIF e adesivi No comment provided by engineer. + + Get notified when mentioned. + Ricevi una notifica quando menzionato. + No comment provided by engineer. + Good afternoon! Buon pomeriggio! @@ -3757,11 +4053,21 @@ Errore: %2$@ Il gruppo verrà eliminato per te. Non è reversibile! No comment provided by engineer. + + Groups + Gruppi + No comment provided by engineer. + Help Aiuto No comment provided by engineer. + + Help admins moderating their groups. + Aiuta gli amministratori a moderare i loro gruppi. + No comment provided by engineer. + Hidden Nascosta @@ -3822,6 +4128,11 @@ Errore: %2$@ Come aiuta la privacy No comment provided by engineer. + + How it works + Come funziona + alert button + How to Come si fa @@ -3964,6 +4275,16 @@ Altri miglioramenti sono in arrivo! Suoni nelle chiamate No comment provided by engineer. + + Inappropriate content + Contenuto inappropriato + report reason + + + Inappropriate profile + Profilo inappropriato + report reason + Incognito Incognito @@ -4056,6 +4377,31 @@ Altri miglioramenti sono in arrivo! Colori dell'interfaccia No comment provided by engineer. + + Invalid + Non valido + token status text + + + Invalid (bad token) + Non valido (token corrotto) + token status text + + + Invalid (expired) + Non valido (scaduto) + token status text + + + Invalid (unregistered) + Non valido (non registrato) + token status text + + + Invalid (wrong topic) + Non valido (argomento sbagliato) + token status text + Invalid QR code Codice QR non valido @@ -4329,6 +4675,21 @@ Questo è il tuo link per il gruppo %@! Desktop collegati No comment provided by engineer. + + List + Elenco + swipe action + + + List name and emoji should be different for all lists. + Il nome dell'elenco e l'emoji dovrebbero essere diversi per tutte le liste. + No comment provided by engineer. + + + List name... + Nome elenco... + No comment provided by engineer. + Live message! Messaggio in diretta! @@ -4414,11 +4775,21 @@ Questo è il tuo link per il gruppo %@! Membro No comment provided by engineer. + + Member admission + Ammissione del membro + No comment provided by engineer. + Member inactive Membro inattivo item status text + + Member reports + Segnalazioni dei membri + chat feature + Member role will be changed to "%@". All chat members will be notified. Il ruolo del membro verrà cambiato in "%@". Verranno notificati tutti i membri della chat. @@ -4444,6 +4815,11 @@ Questo è il tuo link per il gruppo %@! Il membro verrà rimosso dal gruppo, non è reversibile! No comment provided by engineer. + + Member will join the group, accept member? + Il membro entrerà nel gruppo, accettarlo? + alert message + Members can add message reactions. I membri del gruppo possono aggiungere reazioni ai messaggi. @@ -4454,6 +4830,11 @@ Questo è il tuo link per il gruppo %@! I membri del gruppo possono eliminare irreversibilmente i messaggi inviati. (24 ore) No comment provided by engineer. + + Members can report messsages to moderators. + I membri possono segnalare messaggi ai moderatori. + No comment provided by engineer. + Members can send SimpleX links. I membri del gruppo possono inviare link di Simplex. @@ -4479,6 +4860,11 @@ Questo è il tuo link per il gruppo %@! I membri del gruppo possono inviare messaggi vocali. No comment provided by engineer. + + Mention members 👋 + Menziona i membri 👋 + No comment provided by engineer. + Menus Menu @@ -4589,6 +4975,11 @@ Questo è il tuo link per il gruppo %@! I messaggi da %@ verranno mostrati! No comment provided by engineer. + + Messages in this chat will never be deleted. + I messaggi in questa chat non verranno mai eliminati. + alert message + Messages received Messaggi ricevuti @@ -4689,6 +5080,11 @@ Questo è il tuo link per il gruppo %@! Moderato il: %@ copied message info + + More + Altro + swipe action + More improvements are coming soon! Altri miglioramenti sono in arrivo! @@ -4717,7 +5113,12 @@ Questo è il tuo link per il gruppo %@! Mute Silenzia - swipe action + notification label action + + + Mute all + Silenzia tutto + notification label action Muted when inactive! @@ -4769,6 +5170,11 @@ Questo è il tuo link per il gruppo %@! Stato della rete No comment provided by engineer. + + New + Nuovo + token status text + New Passcode Nuovo codice di accesso @@ -4834,6 +5240,11 @@ Questo è il tuo link per il gruppo %@! Nuovo ruolo del membro No comment provided by engineer. + + New member wants to join the group. + Un nuovo membro vuole entrare nel gruppo. + rcv group event chat item + New message Nuovo messaggio @@ -4859,6 +5270,26 @@ Questo è il tuo link per il gruppo %@! Nessuna password dell'app Authentication unavailable + + No chats + Nessuna chat + No comment provided by engineer. + + + No chats found + Nessuna chat trovata + No comment provided by engineer. + + + No chats in list %@ + Nessuna chat nell'elenco %@ + No comment provided by engineer. + + + No chats with members + Nessuna chat con membri + No comment provided by engineer. + No contacts selected Nessun contatto selezionato @@ -4909,6 +5340,11 @@ Questo è il tuo link per il gruppo %@! Nessun server di multimediali e file. servers error + + No message + Nessun messaggio + No comment provided by engineer. + No message servers. Nessun server dei messaggi. @@ -4964,6 +5400,16 @@ Questo è il tuo link per il gruppo %@! Nessun server per inviare file. servers error + + No token! + Nessun token! + alert title + + + No unread chats + Nessuna chat non letta + No comment provided by engineer. + No user identifiers. Nessun identificatore utente. @@ -4974,6 +5420,11 @@ Questo è il tuo link per il gruppo %@! Non compatibile! No comment provided by engineer. + + Notes + Note + No comment provided by engineer. + Nothing selected Nessuna selezione @@ -4994,11 +5445,21 @@ Questo è il tuo link per il gruppo %@! Le notifiche sono disattivate! No comment provided by engineer. + + Notifications error + Errore delle notifiche + alert title + Notifications privacy Privacy delle notifiche No comment provided by engineer. + + Notifications status + Stato delle notifiche + alert title + Now admins can: - delete members' messages. @@ -5021,7 +5482,8 @@ Questo è il tuo link per il gruppo %@! Ok Ok - alert button + alert action +alert button Old database @@ -5082,6 +5544,16 @@ Richiede l'attivazione della VPN. Solo i proprietari del gruppo possono attivare i messaggi vocali. No comment provided by engineer. + + Only sender and moderators see it + Solo il mittente e i moderatori lo vedono + No comment provided by engineer. + + + Only you and moderators see it + Solo tu e i moderatori lo vedete + No comment provided by engineer. + Only you can add message reactions. Solo tu puoi aggiungere reazioni ai messaggi. @@ -5135,7 +5607,7 @@ Richiede l'attivazione della VPN. Open Apri - No comment provided by engineer. + alert action Open Settings @@ -5167,6 +5639,11 @@ Richiede l'attivazione della VPN. Apri gruppo No comment provided by engineer. + + Open link? + Aprire il link? + alert title + Open migration to another device Apri migrazione ad un altro dispositivo @@ -5217,6 +5694,11 @@ Richiede l'attivazione della VPN. O per condividere in modo privato No comment provided by engineer. + + Organize chats into lists + Organizza le chat in elenchi + No comment provided by engineer. + Other Altro @@ -5408,6 +5890,26 @@ Errore: %@ Conserva la password in modo sicuro, NON potrai cambiarla se la perdi. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Prova a disattivare e riattivare le notifiche. + token info + + + Please wait for group moderators to review your request to join the group. + Attendi che i moderatori del gruppo revisionino la tua richiesta di entrare nel gruppo. + snd group event chat item + + + Please wait for token activation to complete. + Attendi il completamento dell'attivazione del token. + token info + + + Please wait for token to be registered. + Attendi la registrazione del token. + token info + Polish interface Interfaccia polacca @@ -5458,16 +5960,31 @@ Errore: %@ Privacy per i tuoi clienti. No comment provided by engineer. + + Privacy policy and conditions of use. + Informativa sulla privacy e condizioni d'uso. + No comment provided by engineer. + Privacy redefined Privacy ridefinita No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server. + No comment provided by engineer. + Private filenames Nomi di file privati No comment provided by engineer. + + Private media file names. + Nomi privati dei file multimediali. + No comment provided by engineer. + Private message routing Instradamento privato dei messaggi @@ -5543,6 +6060,11 @@ Errore: %@ Proibisci le reazioni ai messaggi. No comment provided by engineer. + + Prohibit reporting messages to moderators. + Vieta di segnalare messaggi ai moderatori. + No comment provided by engineer. + Prohibit sending SimpleX links. Vieta l'invio di link di SimpleX. @@ -5800,11 +6322,26 @@ Attivalo nelle impostazioni *Rete e server*. Consumo di batteria ridotto No comment provided by engineer. + + Register + Registra + No comment provided by engineer. + + + Register notification token? + Registrare il token di notifica? + token info + + + Registered + Registrato + token status text + Reject Rifiuta reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5816,6 +6353,11 @@ Attivalo nelle impostazioni *Rete e server*. Rifiuta la richiesta di contatto No comment provided by engineer. + + Reject member? + Rifiutare il membro? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Il server relay viene usato solo se necessario. Un altro utente può osservare il tuo indirizzo IP. @@ -5901,6 +6443,61 @@ Attivalo nelle impostazioni *Rete e server*. Rispondi chat item action + + Report + Segnala + chat item action + + + Report content: only group moderators will see it. + Segnala contenuto: solo i moderatori del gruppo lo vedranno. + report reason + + + Report member profile: only group moderators will see it. + Segnala profilo: solo i moderatori del gruppo lo vedranno. + report reason + + + Report other: only group moderators will see it. + Segnala altro: solo i moderatori del gruppo lo vedranno. + report reason + + + Report reason? + Motivo della segnalazione? + No comment provided by engineer. + + + Report sent to moderators + Segnalazione inviata ai moderatori + alert title + + + Report spam: only group moderators will see it. + Segnala spam: solo i moderatori del gruppo lo vedranno. + report reason + + + Report violation: only group moderators will see it. + Segnala violazione: solo i moderatori del gruppo lo vedranno. + report reason + + + Report: %@ + Segnalazione: %@ + report in notification + + + Reporting messages to moderators is prohibited. + È vietato segnalare messaggi ai moderatori. + No comment provided by engineer. + + + Reports + Segnalazioni + No comment provided by engineer. + Required Obbligatorio @@ -5991,10 +6588,15 @@ Attivalo nelle impostazioni *Rete e server*. Leggi le condizioni No comment provided by engineer. - - Review later - Leggi più tardi - No comment provided by engineer. + + Review members + Revisiona i membri + admission stage + + + Review members before admitting ("knocking"). + Revisiona i membri prima di ammetterli ("bussare"). + admission stage description Revoke @@ -6045,13 +6647,18 @@ Attivalo nelle impostazioni *Rete e server*. Save Salva alert button - chat item action +chat item action Save (and notify contacts) Salva (e avvisa i contatti) alert button + + Save admission settings? + Salvare le impostazioni di ammissione? + alert title + Save and notify contact Salva e avvisa il contatto @@ -6077,6 +6684,11 @@ Attivalo nelle impostazioni *Rete e server*. Salva il profilo del gruppo No comment provided by engineer. + + Save list + Salva elenco + No comment provided by engineer. + Save passphrase and open chat Salva la password e apri la chat @@ -6317,6 +6929,11 @@ Attivalo nelle impostazioni *Rete e server*. Invia notifiche No comment provided by engineer. + + Send private reports + Invia segnalazioni private + No comment provided by engineer. + Send questions and ideas Invia domande e idee @@ -6532,6 +7149,11 @@ Attivalo nelle impostazioni *Rete e server*. Imposta 1 giorno No comment provided by engineer. + + Set chat name… + Imposta il nome della chat… + No comment provided by engineer. + Set contact name… Imposta nome del contatto… @@ -6552,6 +7174,16 @@ Attivalo nelle impostazioni *Rete e server*. Impostalo al posto dell'autenticazione di sistema. No comment provided by engineer. + + Set member admission + Imposta l'ammissione del membro + No comment provided by engineer. + + + Set message expiration in chats. + Imposta la scadenza dei messaggi nelle chat. + No comment provided by engineer. + Set passcode Imposta codice @@ -6596,7 +7228,7 @@ Attivalo nelle impostazioni *Rete e server*. Share Condividi alert action - chat item action +chat item action Share 1-time link @@ -6658,6 +7290,11 @@ Attivalo nelle impostazioni *Rete e server*. Condividi con i contatti No comment provided by engineer. + + Short link + Link breve + No comment provided by engineer. + Show QR code Mostra codice QR @@ -6715,7 +7352,7 @@ Attivalo nelle impostazioni *Rete e server*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. - SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app + SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app. No comment provided by engineer. @@ -6758,6 +7395,11 @@ Attivalo nelle impostazioni *Rete e server*. Indirizzo SimpleX o link una tantum? No comment provided by engineer. + + SimpleX channel link + Link del canale SimpleX + simplex link type + SimpleX contact address Indirizzo di contatto SimpleX @@ -6860,6 +7502,12 @@ Attivalo nelle impostazioni *Rete e server*. Qualcuno notification title + + Spam + Spam + blocking reason +report reason + Square, circle, or anything in between. Quadrata, circolare o qualsiasi forma tra le due. @@ -6945,6 +7593,11 @@ Attivalo nelle impostazioni *Rete e server*. Arresto della chat No comment provided by engineer. + + Storage + Archiviazione + No comment provided by engineer. + Strong Forte @@ -7005,6 +7658,11 @@ Attivalo nelle impostazioni *Rete e server*. Scadenza connessione TCP No comment provided by engineer. + + TCP port for messaging + Porta TCP per i messaggi + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7073,13 +7731,18 @@ Attivalo nelle impostazioni *Rete e server*. Temporary file error Errore del file temporaneo - No comment provided by engineer. + file error alert title Test failed at step %@. Test fallito al passo %@. server test failure + + Test notifications + Prova le notifiche + No comment provided by engineer. + Test server Prova server @@ -7202,21 +7865,11 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il database vecchio non è stato rimosso durante la migrazione, può essere eliminato. No comment provided by engineer. - - The profile is only shared with your contacts. - Il profilo è condiviso solo con i tuoi contatti. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Le stesse condizioni si applicheranno all'operatore **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Le stesse condizioni si applicheranno agli operatori **%@**. - No comment provided by engineer. - The second preset operator in the app! Il secondo operatore preimpostato nell'app! @@ -7282,6 +7935,11 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Questa azione non è reversibile: i messaggi inviati e ricevuti in questa chat prima della selezione verranno eliminati. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. @@ -7327,11 +7985,21 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questo è il tuo link una tantum! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Questo link richiede una versione più recente dell'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Questo link è stato usato con un altro dispositivo mobile, creane uno nuovo sul desktop. No comment provided by engineer. + + This message was deleted or not received yet. + Questo messaggio è stato eliminato o non ancora ricevuto. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**. @@ -7444,6 +8112,11 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Attiva/disattiva l'incognito quando ti colleghi. No comment provided by engineer. + + Token status: %@. + Stato del token: %@. + token status + Toolbar opacity Opacità barra degli strumenti @@ -7609,13 +8282,18 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Unmute Riattiva notifiche - swipe action + notification label action Unread Non letto swipe action + + Unsupported connection link + Link di connessione non supportato + No comment provided by engineer. + Up to 100 last messages are sent to new members. Vengono inviati ai nuovi membri fino a 100 ultimi messaggi. @@ -7641,6 +8319,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Aggiornare le impostazioni? No comment provided by engineer. + + Updated conditions + Condizioni aggiornate + No comment provided by engineer. + Updating settings will re-connect the client to all servers. L'aggiornamento delle impostazioni riconnetterà il client a tutti i server. @@ -7701,6 +8384,16 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usare i server di SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + Usa la porta TCP %@ quando non è specificata alcuna porta. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + Usa la porta TCP 443 solo per i server preimpostati. + No comment provided by engineer. + Use chat Usa la chat @@ -7766,6 +8459,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa i server No comment provided by engineer. + + Use short links (BETA) + Usa link brevi (BETA) + No comment provided by engineer. + Use the app while in the call. Usa l'app mentre sei in chiamata. @@ -7776,6 +8474,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa l'app con una mano sola. No comment provided by engineer. + + Use web port + Usa porta web + No comment provided by engineer. + User selection Selezione utente @@ -8148,11 +8851,6 @@ Ripetere la richiesta di ingresso? Puoi cambiarlo nelle impostazioni dell'aspetto. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Puoi configurare gli operatori nelle impostazioni di rete e server. - No comment provided by engineer. - You can configure servers via settings. Puoi configurare i server nelle impostazioni. @@ -8243,6 +8941,11 @@ Ripetere la richiesta di ingresso? Puoi vedere di nuovo il link di invito nei dettagli di connessione. alert message + + You can view your reports in Chat with admins. + Puoi vedere le tue segnalazioni nella chat con gli amministratori. + alert message + You can't send messages! Non puoi inviare messaggi! @@ -8325,6 +9028,11 @@ Ripetere la richiesta di connessione? Hai inviato un invito al gruppo No comment provided by engineer. + + You should receive notifications. + Dovresti ricevere le notifiche. + token info + You will be connected to group when the group host's device is online, please wait or check later! Verrai connesso/a al gruppo quando il dispositivo dell'host del gruppo sarà in linea, attendi o controlla più tardi! @@ -8390,11 +9098,6 @@ Ripetere la richiesta di connessione? I tuoi server ICE No comment provided by engineer. - - Your SMP servers - I tuoi server SMP - No comment provided by engineer. - Your SimpleX address Il tuo indirizzo SimpleX @@ -8480,6 +9183,11 @@ Ripetere la richiesta di connessione? Verrà condiviso il tuo profilo **%@**. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Il profilo è condiviso solo con i tuoi contatti. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti. I server di SimpleX non possono vedere il tuo profilo. @@ -8490,11 +9198,6 @@ Ripetere la richiesta di connessione? Il tuo profilo è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato a tutti i tuoi contatti. alert message - - Your profile, contacts and delivered messages are stored on your device. - Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo. - No comment provided by engineer. - Your random profile Il tuo profilo casuale @@ -8545,6 +9248,11 @@ Ripetere la richiesta di connessione? sopra, quindi scegli: No comment provided by engineer. + + accepted %@ + %@ accettato + rcv group event chat item + accepted call chiamata accettata @@ -8552,8 +9260,14 @@ Ripetere la richiesta di connessione? accepted invitation + invito accettato chat list item title + + accepted you + ti ha accettato/a + rcv group event chat item + admin amministratore @@ -8574,6 +9288,11 @@ Ripetere la richiesta di connessione? concordando la crittografia… chat item text + + all + tutti + member criteria value + all members tutti i membri @@ -8589,6 +9308,11 @@ Ripetere la richiesta di connessione? e altri %lld eventi No comment provided by engineer. + + archived report + segnalazione archiviata + No comment provided by engineer. + attempts tentativi @@ -8627,7 +9351,8 @@ Ripetere la richiesta di connessione? blocked by admin bloccato dall'amministratore - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8654,6 +9379,11 @@ Ripetere la richiesta di connessione? chiamata… call status + + can't send messages + impossibile inviare messaggi + No comment provided by engineer. + cancelled %@ annullato %@ @@ -8759,6 +9489,16 @@ Ripetere la richiesta di connessione? contatto %1$@ cambiato in %2$@ profile update event chat item + + contact deleted + contatto eliminato + No comment provided by engineer. + + + contact disabled + contatto disattivato + No comment provided by engineer. + contact has e2e encryption il contatto ha la crittografia e2e @@ -8769,6 +9509,11 @@ Ripetere la richiesta di connessione? il contatto non ha la crittografia e2e No comment provided by engineer. + + contact not ready + contatto non pronto + No comment provided by engineer. + creator creatore @@ -8797,7 +9542,8 @@ Ripetere la richiesta di connessione? default (%@) predefinito (%@) - pref value + delete after time +pref value default (no) @@ -8924,21 +9670,11 @@ Ripetere la richiesta di connessione? errore No comment provided by engineer. - - event happened - evento accaduto - No comment provided by engineer. - expired scaduto No comment provided by engineer. - - for better metadata privacy. - per una migliore privacy dei metadati. - No comment provided by engineer. - forwarded inoltrato @@ -8949,6 +9685,11 @@ Ripetere la richiesta di connessione? gruppo eliminato No comment provided by engineer. + + group is deleted + il gruppo è eliminato + No comment provided by engineer. + group profile updated profilo del gruppo aggiornato @@ -9066,7 +9807,7 @@ Ripetere la richiesta di connessione? member %1$@ changed to %2$@ - membro %1$@ cambiato in %2$@ + il membro %1$@ è diventato %2$@ profile update event chat item @@ -9074,6 +9815,11 @@ Ripetere la richiesta di connessione? si è connesso/a rcv group event chat item + + member has old version + il membro ha una versione vecchia + No comment provided by engineer. + message messaggio @@ -9104,20 +9850,20 @@ Ripetere la richiesta di connessione? moderato da %@ marked deleted chat item preview text + + moderator + moderatore + member role + months mesi time unit - - mute - silenzia - No comment provided by engineer. - never mai - No comment provided by engineer. + delete after time new message @@ -9139,6 +9885,11 @@ Ripetere la richiesta di connessione? nessun testo copied message info in history + + not synchronized + non sincronizzato + No comment provided by engineer. + observer osservatore @@ -9148,8 +9899,9 @@ Ripetere la richiesta di connessione? off off enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9191,6 +9943,21 @@ Ripetere la richiesta di connessione? peer-to-peer No comment provided by engineer. + + pending + in attesa + No comment provided by engineer. + + + pending approval + in attesa di approvazione + No comment provided by engineer. + + + pending review + in attesa di revisione + No comment provided by engineer. + quantum resistant e2e encryption crittografia e2e resistente alla quantistica @@ -9206,6 +9973,11 @@ Ripetere la richiesta di connessione? conferma ricevuta… No comment provided by engineer. + + rejected + rifiutato + No comment provided by engineer. + rejected call chiamata rifiutata @@ -9226,6 +9998,11 @@ Ripetere la richiesta di connessione? indirizzo di contatto rimosso profile update event chat item + + removed from group + rimosso dal gruppo + No comment provided by engineer. + removed profile picture immagine del profilo rimossa @@ -9236,10 +10013,26 @@ Ripetere la richiesta di connessione? ti ha rimosso/a rcv group event chat item + + request to join rejected + richiesta di entrare rifiutata + No comment provided by engineer. + requested to connect + richiesto di connettersi chat list item title + + review + revisiona + No comment provided by engineer. + + + reviewed by admins + revisionato dagli amministratori + No comment provided by engineer. + saved salvato @@ -9339,11 +10132,6 @@ ultimo msg ricevuto: %2$@ stato sconosciuto No comment provided by engineer. - - unmute - riattiva notifiche - No comment provided by engineer. - unprotected non protetto @@ -9434,6 +10222,11 @@ ultimo msg ricevuto: %2$@ tu No comment provided by engineer. + + you accepted this member + hai accettato questo membro + snd group event chat item + you are invited to group sei stato/a invitato/a al gruppo @@ -9508,7 +10301,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -9545,7 +10338,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -9567,7 +10360,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -9575,6 +10368,11 @@ ultimo msg ricevuto: %2$@ %d nuovi eventi notification body + + From %d chat(s) + Da %d chat + notification body + From: %@ Da: %@ @@ -9590,16 +10388,11 @@ ultimo msg ricevuto: %2$@ Nuovi messaggi notification - - New messages in %d chats - Nuovi messaggi in %d chat - notification body -
- +
@@ -9621,7 +10414,7 @@ ultimo msg ricevuto: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/it.xcloc/contents.json b/apps/ios/SimpleX Localizations/it.xcloc/contents.json index 13870ab8dd..a42f254bd9 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/it.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "it", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 72e68cff48..2a7bfa8df1 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (コピー可能) @@ -59,7 +47,7 @@ %@ (current) - %@ (現在) + %@ (現在) No comment provided by engineer. @@ -114,10 +102,12 @@ %@ server + %@ サーバー No comment provided by engineer. %@ servers + %@ サーバー No comment provided by engineer. @@ -132,6 +122,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -156,18 +147,22 @@ %d file(s) are still being downloaded. + %d 個のファイルをダウンロードしています。 forward confirmation reason %d file(s) failed to download. + %d 個のファイルがダウンロードに失敗しました。 forward confirmation reason %d file(s) were deleted. + %d 個のファイルが削除されました。 forward confirmation reason %d file(s) were not downloaded. + %d 個のファイルがダウンロードされていません。 forward confirmation reason @@ -177,6 +172,7 @@ %d messages not forwarded + %d 個のメッセージが未転送 alert title @@ -194,6 +190,10 @@ %d 秒 time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d 件のスキップされたメッセージ @@ -206,7 +206,7 @@ %lld - %lld + No comment provided by engineer. @@ -264,11 +264,6 @@ %lldつの新しいインターフェース言語 No comment provided by engineer. - - %lld second(s) - %lld 秒 - No comment provided by engineer. - %lld seconds %lld 秒 @@ -319,11 +314,6 @@ %u 件のメッセージがスキップされました。 No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (新規) @@ -334,11 +324,6 @@ (このデバイス v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **コンタクトの追加**: 新しい招待リンクを作成するか、受け取ったリンクから接続します。 @@ -376,6 +361,7 @@ **Scan / Paste link**: to connect via a link you received. + **QRスキャン / リンクの貼り付け**: 受け取ったリンクで接続する。 No comment provided by engineer. @@ -403,15 +389,13 @@ \*太字* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - [ディレクトリサービス](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) に接続 (ベータ)! +- 配信証明を送信する (最大 20 人まで)。 +- より速く、より安定。 No comment provided by engineer. @@ -427,6 +411,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - 任意で削除された連絡先へ通知します。 +- プロフィール名に空白を含めることができます。 +- and more! No comment provided by engineer. @@ -438,11 +425,6 @@ - 編集履歴。 No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 秒 @@ -456,7 +438,8 @@ 1 day 1日 - time interval + delete after time +time interval 1 hour @@ -471,19 +454,27 @@ 1 month 1ヶ月 - time interval + delete after time +time interval 1 week 1週間 - time interval + delete after time +time interval + + + 1 year + delete after time 1-time link + 使い捨てリンク No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + 使い捨てリンクは、*ひとつの連絡先にのみ* 使用できます - 対面または任意のチャットで共有してください。 No comment provided by engineer. @@ -501,11 +492,6 @@ 30秒 No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -572,8 +558,16 @@ Accept 承諾 accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions @@ -593,7 +587,11 @@ Accept incognito シークレットモードで承諾 accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions @@ -607,6 +605,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -620,6 +622,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile プロフィールを追加 @@ -644,6 +650,10 @@ 別の端末に追加 No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message ウェルカムメッセージを追加 @@ -655,10 +665,12 @@ Added media & file servers + 追加されたメディア & ファイルサーバー No comment provided by engineer. Added message servers + 追加されたメッセージサーバー No comment provided by engineer. @@ -707,6 +719,11 @@ Advanced settings + 詳細設定 + No comment provided by engineer. + + + All No comment provided by engineer. @@ -719,6 +736,10 @@ 全チャットとメッセージが削除されます(※元に戻せません※)! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. 入力するとすべてのデータが消去されます。 @@ -752,8 +773,17 @@ All profiles + すべてのプロフィール profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. あなたの連絡先が繋がったまま継続します。 @@ -794,7 +824,7 @@ Allow irreversible message deletion only if your contact allows it to you. (24 hours) - 送信相手も永久メッセージ削除を許可する時のみに許可する。 + 送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間) No comment provided by engineer. @@ -819,6 +849,7 @@ Allow sharing + 共有を許可 No comment provided by engineer. @@ -826,6 +857,10 @@ 送信済みメッセージの永久削除を許可する。(24時間) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. SimpleXリンクの送信を許可。 @@ -893,6 +928,7 @@ Always use private routing. + プライベートルーティングを常に使用する。 No comment provided by engineer. @@ -905,6 +941,10 @@ 指定された名前の空のチャット プロファイルが作成され、アプリが通常どおり開きます。 No comment provided by engineer. + + Another reason + report reason + Answer call 通話に応答 @@ -930,6 +970,10 @@ アプリは新しいローカルファイル(ビデオを除く)を暗号化します。 No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon アプリのアイコン @@ -961,25 +1005,52 @@ Appearance - 見た目 + アピアランス No comment provided by engineer. Apply + 適用 No comment provided by engineer. Apply to + に適用する + No comment provided by engineer. + + + Archive + No comment provided by engineer. + + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? No comment provided by engineer. Archive and upload + アーカイブとアップロード No comment provided by engineer. Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1083,6 +1154,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1100,6 +1175,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -1186,11 +1265,21 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). チャット プロファイル経由 (デフォルト) または [接続経由](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! 通話は既に終了してます! @@ -1235,7 +1324,7 @@ Cancel 中止 alert action - alert button +alert button Cancel migration @@ -1268,6 +1357,10 @@ 変更 No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1316,7 +1409,7 @@ Change self-destruct passcode 自己破壊パスコードを変更する authentication reason - set passcode view +set passcode view Chat @@ -1407,11 +1500,23 @@ Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats チャット No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1473,6 +1578,14 @@ ダイアログのクリアしますか? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? プライベートノートを消しますか? @@ -1492,6 +1605,10 @@ 色設定 No comment provided by engineer. + + Community guidelines violation + report reason + Compare file ファイルを比較 @@ -1523,14 +1640,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1548,6 +1657,10 @@ ICEサーバを設定 No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm 確認 @@ -1593,6 +1706,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect 接続 @@ -1701,7 +1818,11 @@ This is your own one-time link! Connection and servers status. - 接続とサーバーのステータス + 接続とサーバーのステータス。 + No comment provided by engineer. + + + Connection blocked No comment provided by engineer. @@ -1714,6 +1835,15 @@ This is your own one-time link! 接続エラー (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1723,6 +1853,10 @@ This is your own one-time link! 接続リクエストを送信しました! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1797,6 +1931,10 @@ This is your own one-time link! 連絡先はメッセージを削除対象とすることができます。あなたには閲覧可能です。 No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue 続ける @@ -1865,6 +2003,10 @@ This is your own one-time link! リンクを生成する No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 [デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻 @@ -1872,6 +2014,7 @@ This is your own one-time link! Create profile + プロフィールを作成する No comment provided by engineer. @@ -2073,8 +2216,7 @@ This is your own one-time link! Delete 削除 alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2112,6 +2254,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile チャットのプロフィールを削除する @@ -2122,6 +2268,10 @@ This is your own one-time link! チャットのプロフィールを削除しますか? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2199,6 +2349,10 @@ This is your own one-time link! リンクを削除しますか? No comment provided by engineer. + + Delete list? + alert title + Delete member message? メンバーのメッセージを削除しますか? @@ -2212,7 +2366,7 @@ This is your own one-time link! Delete messages メッセージを削除 - No comment provided by engineer. + alert button Delete messages after @@ -2248,6 +2402,10 @@ This is your own one-time link! 待ち行列を削除 server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2395,6 +2553,14 @@ This is your own one-time link! SimpleXロックを無効にする authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all すべて無効 @@ -2478,9 +2644,13 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address - アドレスを作成しないでください + アドレスを作成しない No comment provided by engineer. @@ -2488,9 +2658,17 @@ This is your own one-time link! 有効にしない No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again 次から表示しない + alert action + + + Done No comment provided by engineer. @@ -2501,7 +2679,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2570,8 +2748,8 @@ This is your own one-time link! 有効にする(設定の優先を維持) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2587,7 +2765,7 @@ This is your own one-time link! Enable automatic message deletion? 自動メッセージ削除を有効にしますか? - No comment provided by engineer. + alert title Enable camera access @@ -2707,6 +2885,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode パスコードを入力 @@ -2777,6 +2959,10 @@ This is your own one-time link! 連絡先リクエストの承諾にエラー発生 No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) メンバー追加にエラー発生 @@ -2809,6 +2995,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -2828,6 +3018,10 @@ This is your own one-time link! グループリンク生成にエラー発生 No comment provided by engineer. + + Error creating list + alert title + Error creating member contact メンバー連絡先の作成中にエラーが発生 @@ -2842,6 +3036,10 @@ This is your own one-time link! プロフィール作成にエラー発生! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file ファイルの復号エラー @@ -2852,6 +3050,10 @@ This is your own one-time link! チャットデータベース削除にエラー発生 No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! チャット削除にエラー発生! @@ -2944,10 +3146,18 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member メンバー除名にエラー発生 - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -2958,6 +3168,10 @@ This is your own one-time link! ICEサーバ保存にエラー発生 No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile グループのプロフィール保存にエラー発生 @@ -3033,6 +3247,10 @@ This is your own one-time link! 接続の同期エラー No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link グループのリンクのアップデートにエラー発生 @@ -3073,7 +3291,9 @@ This is your own one-time link! Error: %@ エラー : %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3107,6 +3327,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database データベースをエキスポート @@ -3145,24 +3369,41 @@ This is your own one-time link! 送信者がオンラインになるまでの待ち時間がなく、速い! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite お気に入り swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3280,6 +3521,10 @@ This is your own one-time link! グループメンバーによる修正はサポートされていません No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3293,6 +3538,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -3397,6 +3646,10 @@ Error: %2$@ GIFとステッカー No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3498,11 +3751,19 @@ Error: %2$@ あなたにとってグループが削除されます (※元に戻せません※)! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help ヘルプ No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden プライベート @@ -3560,6 +3821,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to 使い方 @@ -3692,6 +3957,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito シークレットモード @@ -3762,7 +4035,7 @@ More improvements are coming soon! Instant - すぐに + 即時 No comment provided by engineer. @@ -3781,6 +4054,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -4032,6 +4325,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ライブメッセージ! @@ -4115,10 +4420,18 @@ This is your link for group %@! メンバー No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4142,6 +4455,10 @@ This is your link for group %@! メンバーをグループから除名する (※元に戻せません※)! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. グループメンバーはメッセージへのリアクションを追加できます。 @@ -4152,6 +4469,10 @@ This is your link for group %@! グループのメンバーがメッセージを完全削除することができます。(24時間) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -4176,6 +4497,10 @@ This is your link for group %@! グループのメンバーが音声メッセージを送信できます。 No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4272,6 +4597,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4300,6 +4629,7 @@ This is your link for group %@! Migrate from another device + 別の端末から移行 No comment provided by engineer. @@ -4344,7 +4674,7 @@ This is your link for group %@! Migrations: - 移行 + 移行: No comment provided by engineer. @@ -4362,6 +4692,10 @@ This is your link for group %@! モデレーターによって介入済み: %@ copied message info + + More + swipe action + More improvements are coming soon! まだまだ改善してまいります! @@ -4388,7 +4722,11 @@ This is your link for group %@! Mute ミュート - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4435,6 +4773,10 @@ This is your link for group %@! ネットワーク状況 No comment provided by engineer. + + New + token status text + New Passcode 新しいパスコード @@ -4494,6 +4836,10 @@ This is your link for group %@! 新しいメンバーの役割 No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message 新しいメッセージ @@ -4518,6 +4864,22 @@ This is your link for group %@! アプリのパスワードはありません Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected 連絡先が選択されてません @@ -4565,6 +4927,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -4612,6 +4978,14 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. 世界初のユーザーIDのないプラットフォーム|設計も元からプライベート。 @@ -4621,6 +4995,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4639,10 +5017,18 @@ This is your link for group %@! 通知が無効になっています! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4664,7 +5050,8 @@ This is your link for group %@! Ok OK - alert button + alert action +alert button Old database @@ -4723,6 +5110,14 @@ VPN を有効にする必要があります。 音声メッセージを利用可能に設定できるのはグループのオーナーだけです。 No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. メッセージへのリアクションを追加できるのは、あなただけです。 @@ -4776,7 +5171,7 @@ VPN を有効にする必要があります。 Open 開く - No comment provided by engineer. + alert action Open Settings @@ -4805,6 +5200,10 @@ VPN を有効にする必要があります。 Open group No comment provided by engineer. + + Open link? + alert title + Open migration to another device authentication reason @@ -4845,6 +5244,10 @@ VPN を有効にする必要があります。 Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -5019,6 +5422,22 @@ Error: %@ パスフレーズを失くさないように保管してください。失くすと変更できなくなります。 No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface ポーランド語UI @@ -5065,16 +5484,28 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined プライバシーの基準を新境地に No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames プライベートなファイル名 No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -5085,6 +5516,7 @@ Error: %@ Private notes + プライベートノート name of notes to self @@ -5143,6 +5575,10 @@ Error: %@ メッセージへのリアクションは禁止されています。 No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5377,11 +5813,23 @@ Enable in *Network & servers* settings. 電池使用量低減 No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject 拒否 reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5393,6 +5841,10 @@ Enable in *Network & servers* settings. 連絡要求を拒否する No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. 中継サーバーは必要な場合にのみ使用されます。 別の当事者があなたの IP アドレスを監視できます。 @@ -5471,6 +5923,50 @@ Enable in *Network & servers* settings. 返信 chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required 必須 @@ -5554,9 +6050,13 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5603,13 +6103,17 @@ Enable in *Network & servers* settings. Save 保存 alert button - chat item action +chat item action Save (and notify contacts) 保存(連絡先に通知) alert button + + Save admission settings? + alert title + Save and notify contact 保存して、連絡先にに知らせる @@ -5634,6 +6138,10 @@ Enable in *Network & servers* settings. グループプロフィールの保存 No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat パスフレーズをを保存して、チャットを開始 @@ -5854,6 +6362,10 @@ Enable in *Network & servers* settings. 通知を送信する No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas 質問やアイデアを送る @@ -6042,6 +6554,10 @@ Enable in *Network & servers* settings. 1日に設定 No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… 連絡先の名前を設定… @@ -6061,6 +6577,14 @@ Enable in *Network & servers* settings. システム認証の代わりに設定します。 No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode パスコードを設定する @@ -6102,7 +6626,7 @@ Enable in *Network & servers* settings. Share 共有する alert action - chat item action +chat item action Share 1-time link @@ -6157,6 +6681,10 @@ Enable in *Network & servers* settings. 連絡先と共有する No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6249,6 +6777,10 @@ Enable in *Network & servers* settings. SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX連絡先アドレス @@ -6341,6 +6873,11 @@ Enable in *Network & servers* settings. 誰か notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6420,6 +6957,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6473,6 +7014,10 @@ Enable in *Network & servers* settings. TCP接続タイムアウト No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6535,13 +7080,17 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. テストはステップ %@ で失敗しました。 server test failure + + Test notifications + No comment provided by engineer. + Test server テストサーバ @@ -6658,19 +7207,10 @@ It can happen because of some bug or when the connection is compromised.古いデータベースは移行時に削除されなかったので、削除することができます。 No comment provided by engineer. - - The profile is only shared with your contacts. - プロフィールは連絡先にしか共有されません。 - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -6730,6 +7270,10 @@ It can happen because of some bug or when the connection is compromised.選択中の以前の送受信したメッセージが削除されます (※元に戻せません※)。数分かかります。 No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. あなたのプロフィール、連絡先、メッセージ、ファイルが完全削除されます (※元に戻せません※)。 @@ -6768,10 +7312,18 @@ It can happen because of some bug or when the connection is compromised.This is your own one-time link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. この設定は現在のチャットプロフィール **%@** のメッセージに適用されます。 @@ -6873,6 +7425,10 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -7025,13 +7581,17 @@ To connect, please ask your contact to create another connection link and check Unmute ミュート解除 - swipe action + notification label action Unread 未読 swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -7055,6 +7615,10 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. 設定を更新すると、全サーバにクライントの再接続が行われます。 @@ -7108,6 +7672,14 @@ To connect, please ask your contact to create another connection link and check SimpleX チャット サーバーを使用しますか? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat チャット @@ -7166,6 +7738,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. @@ -7174,6 +7750,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7505,10 +8085,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -7539,6 +8115,7 @@ Repeat join request? You can make it visible to your SimpleX contacts via Settings. + 設定でSimpleXの連絡先に表示させることができます。 No comment provided by engineer. @@ -7592,6 +8169,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! メッセージを送信できませんでした! @@ -7668,6 +8249,10 @@ Repeat connection request? グループの招待を送りました No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! グループのホスト端末がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください! @@ -7730,11 +8315,6 @@ Repeat connection request? あなたのICEサーバ No comment provided by engineer. - - Your SMP servers - あなたのSMPサーバ - No comment provided by engineer. - Your SimpleX address あなたのSimpleXアドレス @@ -7816,6 +8396,11 @@ Repeat connection request? あなたのプロファイル **%@** が共有されます。 No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + プロフィールは連絡先にしか共有されません。 + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. プロフィールはデバイスに保存され、連絡先とのみ共有されます。 SimpleX サーバーはあなたのプロファイルを参照できません。 @@ -7825,11 +8410,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - あなたのプロフィール、連絡先、送信したメッセージがご自分の端末に保存されます。 - No comment provided by engineer. - Your random profile あなたのランダム・プロフィール @@ -7879,6 +8459,10 @@ Repeat connection request? 上で選んでください: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call 受けた通話 @@ -7888,6 +8472,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin 管理者 @@ -7907,6 +8495,10 @@ Repeat connection request? 暗号化に同意しています… chat item text + + all + member criteria value + all members feature role @@ -7920,6 +8512,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -7953,7 +8549,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -7979,6 +8576,10 @@ Repeat connection request? 発信中… call status + + can't send messages + No comment provided by engineer. + cancelled %@ キャンセルされました %@ @@ -8082,6 +8683,14 @@ Repeat connection request? contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption 連絡先はエンドツーエンド暗号化があります @@ -8092,6 +8701,10 @@ Repeat connection request? 連絡先はエンドツーエンド暗号化がありません No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator 作成者 @@ -8119,7 +8732,8 @@ Repeat connection request? default (%@) デフォルト (%@) - pref value + delete after time +pref value default (no) @@ -8244,19 +8858,10 @@ Repeat connection request? エラー No comment provided by engineer. - - event happened - イベント発生 - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8266,6 +8871,10 @@ Repeat connection request? グループ削除済み No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated グループのプロフィールが更新されました @@ -8388,6 +8997,10 @@ Repeat connection request? 接続中 rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8417,19 +9030,19 @@ Repeat connection request? %@ によってモデレートされた marked deleted chat item preview text + + moderator + member role + months time unit - - mute - No comment provided by engineer. - never 一度も - No comment provided by engineer. + delete after time new message @@ -8451,6 +9064,10 @@ Repeat connection request? テキストなし copied message info in history + + not synchronized + No comment provided by engineer. + observer オブザーバー @@ -8460,8 +9077,9 @@ Repeat connection request? off オフ enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -8500,6 +9118,18 @@ Repeat connection request? P2P No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8514,6 +9144,10 @@ Repeat connection request? 確認を受け取りました… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call 拒否した通話 @@ -8533,6 +9167,10 @@ Repeat connection request? removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -8542,10 +9180,22 @@ Repeat connection request? あなたを除名しました rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -8632,10 +9282,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. @@ -8720,6 +9366,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group グループ招待が届きました @@ -8792,7 +9442,7 @@ last received msg: %2$@
- +
@@ -8828,7 +9478,7 @@ last received msg: %2$@
- +
@@ -8850,13 +9500,17 @@ last received msg: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -8869,15 +9523,11 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -8896,7 +9546,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/contents.json b/apps/ios/SimpleX Localizations/ja.xcloc/contents.json index 604a21be97..ce6052fc44 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ja.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ja", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index 9aaa83afc3..019f63cbc0 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -24,216 +24,264 @@ No comment provided by engineer. - + ( + ( No comment provided by engineer. - + (can be copied) + (복사 가능) No comment provided by engineer. - + !1 colored! + !1 색상 적용됨! No comment provided by engineer. - + #secret# + #비밀# No comment provided by engineer. - + %@ + %@ No comment provided by engineer. - + %@ %@ + %@ %@ No comment provided by engineer. - + %@ / %@ + %@ / %@ No comment provided by engineer. - + %@ is connected! + %@이(가) 연결되었습니다! notification title - + %@ is not verified + %@은(는) 인증되지 않았습니다 No comment provided by engineer. - + %@ is verified + %@ 은(는) 인증되었습니다 No comment provided by engineer. - + %@ wants to connect! + %@ 연결을 원함! notification title - + %d days + %d 일 message ttl - + %d hours + %d 시간 message ttl - + %d min + %d 분 message ttl - + %d months + %d 개월 message ttl - + %d sec + %d 초 message ttl - + %d skipped message(s) + 건너뛰기 메시지 %d개 integrity error chat item - + %lld + %lld No comment provided by engineer. - + %lld %@ + %lld %@ No comment provided by engineer. - + %lld contact(s) selected + %lld명의 연락처 선택됨 No comment provided by engineer. - + %lld file(s) with total size of %@ + 총 크기가 %@인 파일 %lld 개 No comment provided by engineer. - + %lld members + %lld명의 멤버 No comment provided by engineer. - + %lld second(s) + %lld 초 No comment provided by engineer. - + %lldd + %lldd No comment provided by engineer. - + %lldh + %lldh No comment provided by engineer. - + %lldk + %lldk No comment provided by engineer. - + %lldm + %lldm No comment provided by engineer. - + %lldmth + %lldmth No comment provided by engineer. %llds + No comment provided by engineer. %lldw No comment provided by engineer. - + ( + ( No comment provided by engineer. - + ) + ) No comment provided by engineer. **Create link / QR code** for your contact to use. No comment provided by engineer. - + **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. + **비공개**: 20분마다 새로운 메시지를 확인합니다. 푸시 서버에는 장치 토큰만 공유됩니다. 연락처 수나 메세지 메타데이터가 표시되지 않습니다. No comment provided by engineer. - + **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app. + **비공개**: SimpleX 채팅 푸시 서버를 사용하지 마세요. 앱은 사용 빈도에 따라 시스템이 허용하는 백그라운드에서 메세지를 확인합니다. No comment provided by engineer. **Paste received link** or open it in the browser and tap **Open in mobile app**. No comment provided by engineer. - + **Please note**: you will NOT be able to recover or change passphrase if you lose it. + **참고**: 비밀번호를 분실하면 복구하거나 변경할 수 없습니다. No comment provided by engineer. - + **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. + **권장**: 디바이스 토큰과 종단 간 암호화 알림이 SimpleX 채팅 푸시 서버로 전송되지만 메세지 내용, 크기 또는 발신자가 표시되지 않습니다. No comment provided by engineer. **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. - + **Warning**: Instant push notifications require passphrase saved in Keychain. + **경고**: 즉각적인 푸시 알림은 암호문을 키체인에 저장해야 합니다. No comment provided by engineer. - + **e2e encrypted** audio call + **e2e** 오디오 통화 No comment provided by engineer. - + **e2e encrypted** video call + **e2e 암호화** 영상 통화 No comment provided by engineer. \*bold* + No comment provided by engineer. - + , + , No comment provided by engineer. - + . + . No comment provided by engineer. - + 1 day + 1일 message ttl - + 1 hour + 1시간 message ttl - + 1 month + 1개월 message ttl - + 1 week + 1주 message ttl 2 weeks message ttl - + 6 + 6 No comment provided by engineer. - + : + : No comment provided by engineer. - + A new contact + 새로운 연결 notification title @@ -244,29 +292,34 @@ A random profile will be sent to your contact No comment provided by engineer. - + A separate TCP connection will be used **for each chat profile you have in the app**. + 앱에 있는 각 채팅 프로필**마다 별도의 TCP 연결이 사용됩니다. No comment provided by engineer. - + A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. + 각 연락처 및 그룹 구성원**마다 별도의 TCP 연결이 사용됩니다. +**참고**: 연결이 많으면 배터리와 트래픽 소비가 상당히 증가하고 일부 연결이 실패할 수 있습니다. No comment provided by engineer. About SimpleX No comment provided by engineer. - + About SimpleX Chat + SimpleX Chat에 대하여 No comment provided by engineer. Accent color No comment provided by engineer. - + Accept + 승인 accept contact request via notification accept incoming call via notification @@ -274,12 +327,14 @@ Accept contact No comment provided by engineer. - + Accept contact request from %@? + %@의 연락 요청을 수락하시겠습니까? notification body - + Accept incognito + 인정하지 않음 No comment provided by engineer. @@ -290,192 +345,233 @@ Add preset servers No comment provided by engineer. - + Add profile + 프로필 추가하기 No comment provided by engineer. - + Add servers by scanning QR codes. + QR 코드를 스캔하여 서버를 추가합니다. No comment provided by engineer. - + Add server + 서버 추가하기 No comment provided by engineer. - + Add to another device + 다른 장치에 추가하기 No comment provided by engineer. - + Add welcome message + 환영 메세지 추가하기 No comment provided by engineer. - + Admins can create the links to join groups. + 관리자는 그룹에 가입할 수 있는 링크를 만들 수 있습니다. No comment provided by engineer. - + Advanced network settings + 고급 네트워크 설정 No comment provided by engineer. - + All chats and messages will be deleted - this cannot be undone! + 모든 채팅과 메세지가 삭제됩니다. - 수정 불가능! No comment provided by engineer. - + All group members will remain connected. + 모든 그룹 구성원은 연결 상태를 유지합니다. No comment provided by engineer. - + All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. + 모든 메세지가 삭제됩니다 - 수정할 수 없습니다! 메세지는 오직 당신만을 위해 삭제될 것입니다. No comment provided by engineer. All your contacts will remain connected No comment provided by engineer. - + Allow + 승인 No comment provided by engineer. - + Allow disappearing messages only if your contact allows it to you. + 연락처가 메세지를 허용하는 경우에만 메세지 삭제를 허용합니다. No comment provided by engineer. Allow irreversible message deletion only if your contact allows it to you. No comment provided by engineer. - + Allow sending direct messages to members. + 회원에게 직접 메시지를 보낼 수 있습니다. No comment provided by engineer. - + Allow sending disappearing messages. + 사라지는 메시지를 보내는 것을 허용합니다. No comment provided by engineer. Allow to irreversibly delete sent messages. No comment provided by engineer. - + Allow to send voice messages. + 음성 메세지를 보낼 수 있습니다. No comment provided by engineer. - + Allow voice messages only if your contact allows them. + 연락처가 음성 메세지를 허용하는 경우에만 음성 메세지를 허용합니다. No comment provided by engineer. - + Allow voice messages? + 음성 메세지를 허용 하겠습니까? No comment provided by engineer. Allow your contacts to irreversibly delete sent messages. No comment provided by engineer. - + Allow your contacts to send disappearing messages. + 연락처가 사라지는 메시지를 보낼 수 있도록 허용합니다. No comment provided by engineer. - + Allow your contacts to send voice messages. + 연락처가 음성 메시지를 보낼 수 있도록 허용합니다. No comment provided by engineer. - + Already connected? + 이미 연결되었나요? No comment provided by engineer. - + Always use relay + 항상 릴레이 사용 No comment provided by engineer. - + Answer call + 응답 전화 No comment provided by engineer. - + App build: %@ + 앱 빌드: %@ No comment provided by engineer. - + App icon + 앱 아이콘 No comment provided by engineer. - + App version + 앱 버전 No comment provided by engineer. - + App version: v%@ + 앱 버전: v%@ No comment provided by engineer. - + Appearance + 출석 No comment provided by engineer. - + Attach + 첨부 No comment provided by engineer. - + Audio & video calls + 음성 & 영상 통화 No comment provided by engineer. - + Audio and video calls + 음성 및 영상 통화 No comment provided by engineer. - + Authentication failed + 인증 실패 No comment provided by engineer. - + Authentication is required before the call is connected, but you may miss calls. + 통화가 연결되기 전에 인증이 필요하지만, 통화를 놓칠 수 있습니다. No comment provided by engineer. - + Authentication unavailable + 인증 사용 불가 No comment provided by engineer. - + Auto-accept contact requests + 연락처 요청 자동 수락 No comment provided by engineer. - + Auto-accept images + 이미지 자동 수락 No comment provided by engineer. Automatically No comment provided by engineer. - + Back + 뒤로가기 No comment provided by engineer. Both you and your contact can irreversibly delete sent messages. No comment provided by engineer. - + Both you and your contact can send disappearing messages. + 당신과 당신의 연락처 모두 사라지는 메시지를 보낼 수 있습니다. No comment provided by engineer. - + Both you and your contact can send voice messages. + 당신과 당신의 연락처 모두 음성 메시지를 보낼 수 있습니다. No comment provided by engineer. - + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + 채팅 프로필(기본값) 또는 [연결](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - + Call already ended! + 통화가 이미 종료되었습니다! No comment provided by engineer. - + Calls + 통화 No comment provided by engineer. @@ -487,8 +583,9 @@ 주소를 초대할 수 없습니다. No comment provided by engineer. - + Can't invite contacts! + 연락처를 초대할 수 없습니다! No comment provided by engineer. @@ -511,8 +608,9 @@ 변경 No comment provided by engineer. - + Change database passphrase? + 데이터베이스 암호 변경? No comment provided by engineer. @@ -540,16 +638,19 @@ 채팅 기록 보관함 No comment provided by engineer. - + Chat console + 채팅 콘솔 No comment provided by engineer. - + Chat database + 채팅 데이터베이스 No comment provided by engineer. - + Chat database deleted + 채팅 데이터베이스 삭제 No comment provided by engineer. @@ -557,80 +658,98 @@ 채팅 데이터베이스를 가져옴 No comment provided by engineer. - + Chat is running + 채팅이 실행 중입니다 No comment provided by engineer. - + Chat is stopped + 채팅이 중단되었습니다 No comment provided by engineer. - + Chat preferences + 채팅 환경설정 No comment provided by engineer. - + Chats + 채팅 No comment provided by engineer. - + Check server address and try again. + 서버 주소를 확인한 후 다시 시도합니다. No comment provided by engineer. - + Chinese and Spanish interface + 중국어 및 스페인어 환경 No comment provided by engineer. - + Choose file + 파일 선택 No comment provided by engineer. - + Choose from library + 라이브러리에서 선택 No comment provided by engineer. - + Clear + 정리 No comment provided by engineer. - + Clear conversation + 대화 삭제 No comment provided by engineer. - + Clear conversation? + 대화 삭제? No comment provided by engineer. - + Clear verification + 인증 삭제 No comment provided by engineer. Colors No comment provided by engineer. - + Compare security codes with your contacts. + 보안 코드를 연락처와 비교합니다. No comment provided by engineer. - + Configure ICE servers + ICE 서버 구성 No comment provided by engineer. - + Confirm + 확인 No comment provided by engineer. - + Confirm new passphrase… + 새 암호 확인… No comment provided by engineer. - + Confirm password + 비밀번호 확인 No comment provided by engineer. - + Connect + 연결 server test step @@ -641,8 +760,9 @@ Connect via group link? No comment provided by engineer. - + Connect via link + 링크를 통해 연결 No comment provided by engineer. @@ -653,174 +773,210 @@ Connect via one-time link? No comment provided by engineer. - + Connecting to server… + 서버에 연결중… No comment provided by engineer. - + Connecting to server… (error: %@) + 서버에 연결중...(오류: %@) No comment provided by engineer. - + Connection + 연결 No comment provided by engineer. - + Connection error + 연결 오류 No comment provided by engineer. - + Connection error (AUTH) + 연결 에러 (인증) No comment provided by engineer. Connection request No comment provided by engineer. - + Connection request sent! + 연결 요청이 전송되었습니다! No comment provided by engineer. - + Connection timeout + 연결 시간초과 No comment provided by engineer. - + Contact allows + 연락 가능 No comment provided by engineer. - + Contact already exists + 연결이 이미 존재 No comment provided by engineer. Contact and all messages will be deleted - this cannot be undone! No comment provided by engineer. - + Contact hidden: + 숨겨진 연락처: notification - + Contact is connected + 연락처가 연결되었습니다 notification Contact is not connected yet! No comment provided by engineer. - + Contact name + 연락처 이름 No comment provided by engineer. - + Contact preferences + 연락처 선호도 No comment provided by engineer. Contact requests No comment provided by engineer. - + Contacts can mark messages for deletion; you will be able to view them. + 연락처는 메세지를 삭제하도록 표시할 수 있으며, 이를 확인할 수 있습니다. No comment provided by engineer. - + Copy + 복사 chat item action Core built at: %@ No comment provided by engineer. - + Core version: v%@ + 코어 버전: v%@ No comment provided by engineer. - + Create + 생성 No comment provided by engineer. Create address No comment provided by engineer. - + Create group link + 그룹 링크 생성 No comment provided by engineer. - + Create link + 링크 생성 No comment provided by engineer. Create one-time invitation link No comment provided by engineer. - + Create queue + 큐 생성 server test step - + Create secret group + 비밀 그룹 생성 No comment provided by engineer. - + Create your profile + 프로필 생성 No comment provided by engineer. Created on %@ No comment provided by engineer. - + Current passphrase… + 현재 암호… No comment provided by engineer. - + Currently maximum supported file size is %@. + 현재 지원되는 최대 파일 크기는 %@입니다. No comment provided by engineer. - + Dark + 다크 No comment provided by engineer. - + Database ID + 데이터베이스 아이디 No comment provided by engineer. - + Database encrypted! + 데이터베이스 암호화됨! No comment provided by engineer. - + Database encryption passphrase will be updated and stored in the keychain. + 데이터베이스 암호화 키가 키체인에 저장됩니다. + No comment provided by engineer. - + Database encryption passphrase will be updated. + 데이터베이스 암호화 키가 업데이트됩니다. + No comment provided by engineer. - + Database error + 데이터베이스 오류 No comment provided by engineer. - + Database is encrypted using a random passphrase, you can change it. + 데이터베이스는 임의의 암호를 사용하여 암호화되므로 변경할 수 있습니다. No comment provided by engineer. - + Database is encrypted using a random passphrase. Please change it before exporting. + 데이터베이스는 임의의 암호를 사용하여 암호화됩니다. 내보내기 전에 변경하십시오. No comment provided by engineer. - + Database passphrase + 데이터베이스 암호화 키 No comment provided by engineer. - + Database passphrase & export + 데이터베이스 암호화 키 & 내보내기 No comment provided by engineer. @@ -2009,8 +2165,9 @@ We will be adding server redundancy to prevent lost messages. Open user profiles authentication reason - + Anybody can host servers. + 누구나 서버를 호스팅할 수 있습니다. No comment provided by engineer. @@ -2710,8 +2867,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. @@ -3784,6 +3941,1186 @@ SimpleX servers cannot see your profile. 새로운 멤버에게 최대 100개의 마지막 메시지 보내기. No comment provided by engineer. + + ## History + ## 기록 + + + ## In reply to + ## 에 대한 답변 + + + %@ downloaded + %@ 다운로드됨 + + + # %@ + # %@ + + + %@ and %@ + %@ 그리고 %@ + + + %1$@ at %2$@: + %2$@의 %1$@: + + + %@ connected + %@ 연결됨 + + + %@ (current): + %@ (현재): + + + %@ (current) + %@ (현재) + + + %@ and %@ connected + %@ 및 %@이(가) 연결되었습니다 + + + %@ server + %@서버 + + + %@ servers + %@서버들 + + + %@, %@ and %lld members + %@, %@ 과 %lld 멤버들 + + + %d file(s) are still being downloaded. + %d 개의 파일 다운로드중. + + + %d file(s) were deleted. + %d개의 파일이 삭제됨. + + + %d file(s) were not downloaded. + %d개의 파일이 다운로드 되지 않음. + + + %d weeks + %d 주 + + + %lld seconds + %lld 초 + + + **Create 1-time link**: to create and share a new invitation link. + **1회 링크 생성** : 새 초대 링크를 생성하고 공유합니다. + + + 1-time link + 일회성 링크 + + + 1-time link can be used *with one contact only* - share in person or via any messenger. + 일회용 링크는 *한 번의 연락처로만* 사용할 수 있으며, 대면 또는 메신저를 통해 공유할 수 있습니다. + + + A few more things + 몇 가지 더 + + + Accept conditions + 조건 수락 + + + Accepted conditions + 수락된 조건 + + + Active connections + 연결 활성화 + + + %@ uploaded + %@업로드됨 + + + Accept connection request? + 연결 요청을 수락하시겠습니까? + + + %lld minutes + %lld 분 + + + **Warning**: the archive will be removed. + **경고**: 보관물이 제거됩니다. + + + 5 minutes + 5 분 + + + Abort changing address + 주소 변경 중단 + + + Acknowledgement errors + 확인 오류 + + + Abort + 중단 + + + %u messages failed to decrypt. + %u개의 메세지를 번역하는데 실패함. + + + Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. + 연락처가 다른 사람과 공유할 수 있도록 프로필에 주소를 추가합니다. 프로필 업데이트가 연락처로 전송됩니다. + + + %lld messages blocked by admin + 관리자에 의해 차단된 %lld개의 메세지 + + + **Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection. + **참고**: 두 장치에서 동일한 데이터베이스를 사용하면 보안 보호를 위해 연결에서 메세지를 해독할 수 있습니다. + + + **Create group**: to create a new group. + **그룹 생성** : 새로운 그룹을 생성합니다. + + + %d file(s) failed to download. + %d개의 파일을 다운로드하는데 실패함. + + + %d messages not forwarded + %d개의 메세지가 전달되지 않음 + + + **Scan / Paste link**: to connect via a link you received. + **스캔/붙여넣기 링크**: 받은 링크를 통해 연결합니다. + + + About operators + 연산자 정보 + + + Address change will be aborted. Old receiving address will be used. + 주소 변경이 중단됩니다. 이전 수신 주소가 사용됩니다. + + + %@: + %@: + + + %lld messages blocked + %lld개의 메세지가 차단됨 + + + %lld messages marked deleted + 삭제된 메세지 %lld 개 + + + - more stable message delivery. +- a bit better groups. +- and more! + - 보다 안정적인 메세지 전달. +- 조금 더 나은 그룹. +- 그리고 더! + + + 0s + 0초 + + + 1 minute + 1분 + + + Abort changing address? + 주소 변경을 중단하시겠습니까? + + + 30 seconds + 30초 + + + - voice messages up to 5 minutes. +- custom time to disappear. +- editing history. + - 음성 메세지 최대 5분. +- 사라지는 맞춤형 시간. +- 편집 기록. + + + Add friends + 친구 추가 + + + Add team members + 팀원 추가하기 + + + Add your team members to the conversations. + 대화에 팀원을 추가하세요. + + + %u messages skipped. + 메세지 %u개를 건너뜀. + + + %@, %@ and %lld other members connected + %@, %@ 그리고 %lld 다른 멤버들이 연결됨 + + + %lld messages moderated by %@ + %@ 에 의해 중재된 %lld 개의 메세지 + + + %lld new interface languages + %lld개의 새로운 인터페이스 언어 + + + %1$@, %2$@ + %1$@, %2$@ + + + - optionally notify deleted contacts. +- profile names with spaces. +- and more! + - 선택적으로 삭제된 연락처를 통지합니다. +- 공백이 있는 프로필 이름. +- 그리고 더! + + + <p>Hi!</p> +<p><a href="%@">Connect to me via SimpleX Chat</a></p> + <p>안녕하세요!/p> +<p><a href="%@">SimpleX 채팅을 통해 저에게 연결하세요 </a></p> + + + A new random profile will be shared. + 새로운 랜덤 프로필이 공유될 것입니다. + + + Acknowledged + 인정된 + + + Additional accent 2 + 추가 악센트2 + + + Added media & file servers + 미디어 및 파일 서버 추가 + + + Added message servers + 추가된 메세지 서버 + + + Additional accent + 추가 악센트 + + + Additional secondary + 추가적 보조 + + + Address + 주소 + + + Address or 1-time link? + 주소 또는 일회성 링크? + + + Address settings + 주소 세팅 + + + Admins can block a member for all. + 관리자는 모두를 위해 회원을 차단할 수 있습니다. + + + %lld group events + %lld개의 그룹 이벤트 + + + All app data is deleted. + 모든 앱 데이터가 삭제됩니다. + + + All data is erased when it is entered. + 입력하면 모든 데이터가 삭제됩니다. + + + 0 sec + 0 초 + + + (this device v%@) + (이 장치 v%@) + + + (new) + (새로운) + + + Advanced settings + 고급 설정 + + + All data is kept private on your device. + 모든 데이터는 기기에서 비공개로 유지됩니다. + + + All profiles + 전체 프로필 + + + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + 모든 연락처, 대화 및 파일은 안전하게 암호화되어 구성된 XFTP 릴레이에 청크로 업로드됩니다. + + + Allow calls only if your contact allows them. + 허용된 연락처만 통화가 가능합니다. + + + Allow calls? + 통화 허용? + + + Allow downgrade + 강등 허용 + + + Allow irreversible message deletion only if your contact allows it to you. (24 hours) + 연락처가 허용하는 경우에만 수정 불가능한 메세지 삭제를 허용합니다. (24시간) + + + Allow to send files and media. + 파일과 미디어를 전송할 수 있습니다. + + + Archiving database + 보관된 데이터베이스 + + + Better calls + 더 나은 통화 + + + Block + 차단 + + + Conditions will be accepted for enabled operators after 30 days. + 30일 후에 활성화된 운영자에 대한 조건이 수락될 것입니다. + + + Conditions will be accepted on: %@. + 조건은 다음과 같습니다: %@. + + + Connect via one-time link + 일회성 링크를 통해 연결 + + + Connected desktop + 데스크톱과 연결됨 + + + Connected servers + 연결된 서버 + + + Connection security + 연결 보안 + + + Connection terminated + 종료된 연결 + + + Connection with desktop stopped + 데스크톱과의 연결이 중지됨 + + + Current conditions text couldn't be loaded, you can review conditions via this link: + 현재 조건 텍스트를 로드할 수 없습니다. 이 링크를 통해 조건을 검토할 수 있습니다: + + + Bad desktop address + 잘못된 데스크톱 주소 + + + Camera not available + 카메라가 사용 불가능합니다 + + + Custom time + 사용자 지정 시간 + + + Allow to irreversibly delete sent messages. (24 hours) + 보낸 메시지를 되돌릴 수 없도록 삭제합니다. (24시간) + + + Allow message reactions. + 메세지 응답 허용. + + + Allow your contacts adding message reactions. + 연락처가 메세지 응답을 추가하도록 허용합니다. + + + Already connecting! + 이미 연결 중입니다! + + + Already joining the group! + 그룹에 참가하는 중입니다! + + + Archive and upload + 기록 및 업로드 + + + Chat colors + 채팅 색깔 + + + Chat list + 채팅 목록 + + + Completed + 완료됨 + + + Copy error + 복사 오류 + + + Create SimpleX address + SimpleX 주소 생성 + + + Creating link… + 생성 링크… + + + Blocked by admin + 관리자에 의해 차단됨 + + + Connect to desktop + 데스크톱에 연결 + + + Created at + 에 생성됨 + + + Created at: %@ + 생성 위치: %@ + + + Change self-destruct passcode + 자기-파괴 비밀번호 변경 + + + Create file + 파일 생성 + + + Allow your contacts to irreversibly delete sent messages. (24 hours) + 연락처가 보낸 메세지를 되돌릴 수 없도록 삭제할 수 있도록 허용합니다. (24시간) + + + App data migration + 앱 데이터 이동 + + + Apply to + 적용 대상 + + + Block for all + 모두를 위한 차단 + + + Both you and your contact can add message reactions. + 당신과 당신의 연락처 모두 메세지 반응을 추가할 수 있습니다. + + + Calls prohibited! + 통화 금지! + + + Change self-destruct mode + 자기-파괴 모드 변경 + + + Contacts + 연락처 + + + Create group + 그룹 생성 + + + Both you and your contact can make calls. + 당신과 당신의 연락처 모두 전화를 걸 수 있습니다. + + + App passcode + 앱 비밀번호 + + + All your contacts will remain connected. Profile update will be sent to your contacts. + 당신의 모든 연락은 연결되어 있습니다. 프로필 업데이트가 모든 연락으로 전송됩니다. + + + App encrypts new local files (except videos). + 앱은 새로운 로컬 파일을 암호화합니다 (동영상 제외). + + + Chat preferences were changed. + 채팅 환경설정이 변경되었습니다. + + + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + [데스크톱 앱]에 새로운 프로필 생성(https://simplex.chat/downloads/).💻 + + + Contact is deleted. + 연락처가 삭제되었습니다. + + + Continue + 계속 + + + Current Passcode + 현재 비밀번호 + + + An empty chat profile with the provided name is created, and the app opens as usual. + 제공된 이름으로 빈 채팅 프로필이 생성되고 앱이 정상적으로 열립니다. + + + Allow your contacts to call you. + 연락처가 전화할 수 있도록 허용합니다. + + + Allow sharing + 공유 허용 + + + Always use private routing. + 항상 개인 경로를 사용합니다. + + + Better user experience + 더 나은 사용자 경험 + + + Change lock mode + 잠금 모드 변경 + + + Allow message reactions only if your contact allows them. + 연락처가 메세지 응답을 허용하는 경우에만 메세지 응답을 허용합니다. + + + Better security ✅ + 더 나은 안전✅ + + + Both you and your contact can irreversibly delete sent messages. (24 hours) + 당신과 당신의 연락처 모두 보낸 메세지를 되돌릴 수 없습니다. (24시간) + + + Confirm contact deletion? + 연락처 삭제를 확인하시겠습니까? + + + Can't call contact + 연락처에 전화할 수 없습니다 + + + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + 불가리아어, 핀란드어, 태국어, 우크라이나어 - 사용자 여러분과 [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)에 감사드립니다! + + + Capacity exceeded - recipient did not receive previously sent messages. + 용량 초과 - 수신자가 이전에 보낸 메세지를 받지 못했습니다. + + + Chat + 채팅 + + + Connect to yourself? +This is your own one-time link! + 자신에게 연결할까요? +이것은 당신만의 일회성 링크입니다! + + + Choose _Migrate from another device_ on the new device and scan QR code. + 새 기기에서 _다른 기기에서 이동_을 선택하고 QR 코드를 스캔합니다. + + + Connecting to desktop + 데스크톱에 연결중 + + + Connect with %@ + %@와 연결 + + + Archived contacts + 보관된 연락처 + + + Better message dates. + 더 나은 메세지 날짜. + + + Better networking + 더 나은 네트워킹 + + + Check messages when allowed. + 허용될 때 메시지를 확인합니다. + + + Compare file + 파일 비교 + + + Conditions will be automatically accepted for enabled operators on: %@. + 다음 조건은 활성화된 운영자에 대해 자동으로 수락됩니다: %@. + + + Confirm upload + 업로드 확인 + + + Connect incognito + 비밀 연결 + + + Connect to your friends faster. + 친구들과 더 빨리 연결하세요. + + + Connect to yourself? + 자신과 연결할까요? + + + Created + 생성됨 + + + Creating archive link + 기록 링크 생성하기 + + + Auto-accept + 자동 수락 + + + All new messages from %@ will be hidden! + %@로부터의 모든 새 메세지가 숨겨집니다! + + + Auto-accept settings + 자동-수락 설정 + + + Archive contacts to chat later. + 나중에 채팅할 연락처를 보관합니다. + + + Background + 배경 + + + Bad message hash + 잘못된 메세지 hash + + + Better groups + 더 나은 그룹 + + + Better messages + 더 나은 메세지 + + + Chunks downloaded + 다운로드된 청크 + + + Chunks deleted + 삭제된 청크 + + + Chunks uploaded + 업로드 된 청크 + + + Corner + 코너 + + + Correct name to %@? + %@의 정확한 이름은? + + + Create a group using a random profile. + 랜덤 프로필을 사용하여 그룹을 만듭니다. + + + Authentication cancelled + 인증 취소 + + + Confirm Passcode + 비밀번호 확인 + + + Confirm database upgrades + 데이터베이스 업그레이드 확인 + + + Blur media + 가려진 미디어 + + + Block group members + 그룹 구성원 차단 + + + Connected + 연결됨 + + + All messages will be deleted - this cannot be undone! + 모든 메세지가 삭제됩니다 - 수정할 수 없습니다! + + + All your contacts will remain connected. + 당신의 모든 연락은 계속 연결되어 있습니다. + + + Allow to send SimpleX links. + SinpleX 링크 전송 허용. + + + Bad message ID + 잘못된 메세지 ID + + + Black + 블랙 + + + Block member + 차단 구성원 + + + Connected to desktop + 데스크톱과 연결됨 + + + App passcode is replaced with self-destruct passcode. + 앱 비밀번호는 자체-파괴 비밀번호로 대체됩니다. + + + Apply + 적용 + + + Better notifications + 더 나은 공지 + + + Block member? + 차단 멤버? + + + Blur for better privacy. + 더 나은 개인정보를 위해 흐림. + + + Business address + 사업체 주소 + + + Business chats + 비즈니스 채팅 + + + Can't call member + 회원에게 전화할 수 없습니다 + + + Can't message member + 멤버에게 메세지를 보낼 수 없습니다 + + + Cancel migration + 이동 취소 + + + Change chat profiles + 채팅 프로필 변경 + + + Chat already exists + 채팅이 이미 존재합니다 + + + Chat already exists! + 채팅이 이미 존재합니다! + + + Chat database exported + 채팅 데이터베이스 내보내기 + + + Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. + 채팅이 중지되었습니다. 이미 다른 장치에서 이 데이터베이스를 사용하고 있다면 채팅을 시작하기 전에 다시 전송해야 합니다. + + + Chat migrated! + 채팅 이동! + + + Chat theme + 채팅 테마 + + + Chat will be deleted for all members - this cannot be undone! + 채팅은 모든 회원에게 삭제됩니다 - 이는 되돌릴 수 없습니다! + + + Chat profile + 채팅 프로필 + + + Chat will be deleted for you - this cannot be undone! + 채팅은 삭제됩니다 - 되돌릴 수 없습니다! + + + Check messages every 20 min. + 20분마다 메시지를 확인합니다. + + + Color chats with the new themes. + 새로운 테마로 채팅을 색칠하세요. + + + Color mode + 색깔 모드 + + + Clear private notes? + 개인 메모를 지우시겠습니까? + + + Conditions accepted on: %@. + 조건이 수락됨: %@. + + + Conditions are accepted for the operator(s): **%@**. + 운영자의 조건이 허용됩니다: **%@**. + + + Conditions of use + 이용 조건 + + + Conditions will be accepted for operator(s): **%@**. + 운영자 조건이 수락됩니다: **%@**. + + + Conditions will be accepted for the operator(s): **%@**. + 운영자 조건이 수락됩니다.: **%@**. + + + Confirm that you remember database passphrase to migrate it. + 이동하는데에 필요한 데이터베이스 비밀번호를 기억하는지 확인합니다. + + + Connect to yourself? +This is your own SimpleX address! + 자신과 연결할까요? +이것은 당신의 SimpleX 주소입니다! + + + Connect via contact address + 연락처 주소로 연결 + + + Connecting + 연결중 + + + Connecting to contact, please wait or check later! + 연락처에 연결 중이니 기다려 주시거나 나중에 확인해 주세요! + + + Connection and servers status. + 연결 및 서버 상태. + + + Connection notifications + 연결 공지 + + + Connections + 연결 + + + Contact will be deleted - this cannot be undone! + 연락처가 삭제됩니다 - 취소할 수 없습니다! + + + Conversation deleted! + 대화가 삭제되었습니다! + + + Create 1-time link + 일회성 링크 생성 + + + Create profile + 프로필 생성 + + + Audio/video calls + 음성/영상 통화 + + + Audio/video calls are prohibited. + 음성/영상 통화는 금지되어 있습니다. + + + App session + 앱 세션 + + + Block member for all? + 모두를 위한 차단 멤버? + + + Cannot forward message + 메세지를 전달할 수 없습니다 + + + Confirm network settings + 네트워크 설정 확인 + + + Connect automatically + 자동으로 연결 + + + Confirm files from unknown servers. + 알 수 없는 서버에서 파일을 확인합니다. + + + Conditions are already accepted for these operator(s): **%@**. + 이 운영자들에 대한 조건은 이미 받아들여지고 있습니다: **%@**. + + + Contact deleted! + 연락처 삭제! + + + Current profile + 현재 프로필 + + + Customizable message shape. + 사용자 지정 가능한 메세지 형태. + + + %d seconds(s) + %d 초 + + + 1 year + 1년 + + + Add list + 리스트 추가 + + + Add to list + 리스트에 추가 + + + All + 모두 + + + Allow to report messsages to moderators. + 메시지를 신고하는것을 허용합니다. + + + Another reason + 다른 이유 + + + App group: + 앱 그룹: + + + Archive + 아카이브 + + + Archive report + 신고 아카이브 + + + Archive report? + 신고를 아카이브할까요? + + + Archive reports + 신고 아카이브 + + + Ask + 묻기 + + + Clear group? + 그룹을 비울까요? + + + Clear or delete group? + 그룹을 비우거나 삭제할까요? + + + Community guidelines violation + 커뮤니티 지침 위반 + + + Connection blocked + 연결 차단됨 + + + Connection is blocked by server operator: +%@ + 서버 관리자에 의해 연결이 차단되었습니다: +%@ + + + Connection not ready. + 연결 준비되지 않음. + + + Connection requires encryption renegotiation. + 연결에는 암호화 재협상이 필요합니다. + + + Content violates conditions of use + 내용은 사용 규정을 위반합니다 + + + Create list + 리스트 추가 + + + Database ID: %d + 데이터베이스 아이디: %d + + + Database IDs and Transport isolation option. + 데이터베이스 ID 및 전송 격리 옵션. + + + Database downgrade + 데이터베이스 다운그레이드 + + + Better groups performance + 더 나은 그룹 성능 + + + Confirmed + 확인함 + + + Active + 활성화됨 + + + Archive all reports? + 모든 신고를 아카이브할까요? + + + Businesses + 비즈니스 + + + Better privacy and security + 더 나은 프라이버시 및 보안 + + + Change automatic message deletion? + 자동 메시지 삭제를 변경할까요? + + + All chats will be removed from the list %@, and the list deleted. + 모든 채팅은 %@ 리스트에서 제거되고 리스트는 삭제됩니다. + + + All reports will be archived for you. + 모든 보고서는 사용자를 위해 보관됩니다. + + + Accent + 강조 + + + Archive %lld reports? + %lld 신고를 아카이브할까요? + + + - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! +- delivery receipts (up to 20 members). +- faster and more stable. + - [경로 서비스](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA) 에 연결중! +- 전달 확인 (최대 20 명의 멤버). +- 더 빠르고 안정적입니다. + + + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + 모든 메시지와 파일은 **종간단 암호화 (E2EE)**되며, 개인 메시지는 양자 보안이 적용됩니다. + + + Customize theme + 테마 사용자 지정 + + + Dark mode colors + 다크 모드 색상들 +
diff --git a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff index 54a713478f..0f795170c6 100644 --- a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff +++ b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff @@ -2631,8 +2631,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer.
- - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index ab3499a4dc..d0b430cf02 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (kan gekopieerd worden) @@ -202,6 +190,11 @@ %d sec time interval + + %d seconds(s) + %d seconden + delete after time + %d skipped message(s) %d overgeslagen bericht(en) @@ -272,11 +265,6 @@ %lld nieuwe interface-talen No comment provided by engineer. - - %lld second(s) - %lld seconde(n) - No comment provided by engineer. - %lld seconds %lld seconden @@ -327,11 +315,6 @@ %u berichten zijn overgeslagen. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nieuw) @@ -342,11 +325,6 @@ (dit apparaat v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Contact toevoegen**: om een nieuwe uitnodigingslink aan te maken, of verbinding te maken via een link die u heeft ontvangen. @@ -412,11 +390,6 @@ \*vetgedrukt* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -453,11 +426,6 @@ - bewerkingsgeschiedenis. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -471,7 +439,8 @@ 1 day 1 dag - time interval + delete after time +time interval 1 hour @@ -486,12 +455,19 @@ 1 month 1 maand - time interval + delete after time +time interval 1 week 1 week - time interval + delete after time +time interval + + + 1 year + 1 jaar + delete after time 1-time link @@ -518,11 +494,6 @@ 30 seconden No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -591,8 +562,16 @@ Accept Accepteer accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions @@ -613,7 +592,11 @@ Accept incognito Accepteer incognito accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions @@ -630,6 +613,11 @@ Bevestigingsfouten No comment provided by engineer. + + Active + actief + token status text + Active connections Actieve verbindingen @@ -645,6 +633,11 @@ Vrienden toevoegen No comment provided by engineer. + + Add list + Lijst toevoegen + No comment provided by engineer. + Add profile Profiel toevoegen @@ -670,6 +663,11 @@ Toevoegen aan een ander apparaat No comment provided by engineer. + + Add to list + Toevoegen aan lijst + No comment provided by engineer. + Add welcome message Welkom bericht toevoegen @@ -745,6 +743,11 @@ Geavanceerde instellingen No comment provided by engineer. + + All + alle + No comment provided by engineer. + All app data is deleted. Alle app-gegevens worden verwijderd. @@ -755,6 +758,11 @@ Alle chats en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Alle chats worden uit de lijst %@ verwijderd en de lijst wordt verwijderd. + alert message + All data is erased when it is entered. Alle gegevens worden bij het invoeren gewist. @@ -795,6 +803,15 @@ Alle profielen profile dropdown + + All reports will be archived for you. + Alle rapporten worden voor u gearchiveerd. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Al uw contacten blijven verbonden. @@ -870,6 +887,11 @@ Sta toe om verzonden berichten definitief te verwijderen. (24 uur) No comment provided by engineer. + + Allow to report messsages to moderators. + Hiermee kunt u berichten rapporteren aan moderators. + No comment provided by engineer. + Allow to send SimpleX links. Sta toe dat SimpleX-links worden verzonden. @@ -950,6 +972,11 @@ Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. No comment provided by engineer. + + Another reason + Een andere reden + report reason + Answer call Beantwoord oproep @@ -975,6 +1002,11 @@ App versleutelt nieuwe lokale bestanden (behalve video's). No comment provided by engineer. + + App group: + App-groep: + No comment provided by engineer. + App icon App icon @@ -1020,6 +1052,21 @@ Toepassen op No comment provided by engineer. + + Archive + Archief + No comment provided by engineer. + + + Archive %lld reports? + %lld rapporten archiveren? + No comment provided by engineer. + + + Archive all reports? + Alle rapporten archiveren? + No comment provided by engineer. + Archive and upload Archiveren en uploaden @@ -1030,6 +1077,21 @@ Archiveer contacten om later te chatten. No comment provided by engineer. + + Archive report + Rapport archiveren + No comment provided by engineer. + + + Archive report? + Rapport archiveren? + No comment provided by engineer. + + + Archive reports + Rapporten archiveren + swipe action + Archived contacts Gearchiveerde contacten @@ -1140,6 +1202,11 @@ Betere groepen No comment provided by engineer. + + Better groups performance + Betere prestaties van groepen + No comment provided by engineer. + Better message dates. Betere datums voor berichten. @@ -1160,6 +1227,11 @@ Betere meldingen No comment provided by engineer. + + Better privacy and security + Betere privacy en veiligheid + No comment provided by engineer. + Better security ✅ Betere beveiliging ✅ @@ -1260,11 +1332,25 @@ Zakelijke chats No comment provided by engineer. + + Businesses + bedrijven + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Door SimpleX Chat te gebruiken, gaat u ermee akkoord: +- alleen legale content te versturen in openbare groepen. +- andere gebruikers te respecteren – geen spam. + No comment provided by engineer. + Call already ended! Oproep al beëindigd! @@ -1314,7 +1400,7 @@ Cancel Annuleren alert action - alert button +alert button Cancel migration @@ -1351,6 +1437,11 @@ Veranderen No comment provided by engineer. + + Change automatic message deletion? + Automatisch verwijderen van berichten wijzigen? + alert title + Change chat profiles Gebruikersprofielen wijzigen @@ -1400,7 +1491,7 @@ Change self-destruct passcode Zelfvernietigings code wijzigen authentication reason - set passcode view +set passcode view Chat @@ -1502,11 +1593,23 @@ De chat wordt voor je verwijderd - dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Chats No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Controleer uw berichten elke 20 minuten. @@ -1572,6 +1675,16 @@ Gesprek wissen? No comment provided by engineer. + + Clear group? + Groep wissen? + No comment provided by engineer. + + + Clear or delete group? + Groep wissen of verwijderen? + No comment provided by engineer. + Clear private notes? Privénotities verwijderen? @@ -1592,6 +1705,11 @@ Kleur mode No comment provided by engineer. + + Community guidelines violation + Schending van de communityrichtlijnen + report reason + Compare file Bestand vergelijken @@ -1627,16 +1745,6 @@ Gebruiksvoorwaarden No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Voor ingeschakelde operators worden de voorwaarden na 30 dagen geaccepteerd. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Voorwaarden worden geaccepteerd voor operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Voorwaarden worden geaccepteerd voor de operator(s): **%@**. @@ -1657,6 +1765,11 @@ ICE servers configureren No comment provided by engineer. + + Configure server operators + Serveroperators configureren + No comment provided by engineer. + Confirm Bevestigen @@ -1707,6 +1820,11 @@ Bevestig het uploaden No comment provided by engineer. + + Confirmed + Bevestigd + token status text + Connect Verbind @@ -1826,6 +1944,11 @@ Dit is uw eigen eenmalige link! Verbindings- en serverstatus. No comment provided by engineer. + + Connection blocked + Verbinding geblokkeerd + No comment provided by engineer. + Connection error Verbindingsfout @@ -1836,6 +1959,18 @@ Dit is uw eigen eenmalige link! Verbindingsfout (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + Verbinding is geblokkeerd door serveroperator: +%@ + No comment provided by engineer. + + + Connection not ready. + Verbinding nog niet klaar. + No comment provided by engineer. + Connection notifications Verbindingsmeldingen @@ -1846,6 +1981,11 @@ Dit is uw eigen eenmalige link! Verbindingsverzoek verzonden! No comment provided by engineer. + + Connection requires encryption renegotiation. + Verbinding vereist heronderhandeling over encryptie. + No comment provided by engineer. + Connection security Beveiliging van de verbinding @@ -1926,6 +2066,11 @@ Dit is uw eigen eenmalige link! Contact personen kunnen berichten markeren voor verwijdering; u kunt ze wel bekijken. No comment provided by engineer. + + Content violates conditions of use + Inhoud schendt de gebruiksvoorwaarden + blocking reason + Continue Doorgaan @@ -2001,6 +2146,11 @@ Dit is uw eigen eenmalige link! Maak link No comment provided by engineer. + + Create list + Maak een lijst + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Maak een nieuw profiel aan in [desktop-app](https://simplex.chat/downloads/). 💻 @@ -2218,8 +2368,7 @@ Dit is uw eigen eenmalige link! Delete Verwijderen alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2261,6 +2410,11 @@ Dit is uw eigen eenmalige link! Chat verwijderen No comment provided by engineer. + + Delete chat messages from your device. + Verwijder chatberichten van uw apparaat. + No comment provided by engineer. + Delete chat profile Chatprofiel verwijderen @@ -2271,6 +2425,10 @@ Dit is uw eigen eenmalige link! Chatprofiel verwijderen? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Chat verwijderen? @@ -2351,6 +2509,11 @@ Dit is uw eigen eenmalige link! Link verwijderen? No comment provided by engineer. + + Delete list? + Lijst verwijderen? + alert title + Delete member message? Bericht van lid verwijderen? @@ -2364,7 +2527,7 @@ Dit is uw eigen eenmalige link! Delete messages Verwijder berichten - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2564,11 @@ Dit is uw eigen eenmalige link! Wachtrij verwijderen server test step + + Delete report + Rapport verwijderen + No comment provided by engineer. + Delete up to 20 messages at once. Verwijder maximaal 20 berichten tegelijk. @@ -2561,6 +2729,16 @@ Dit is uw eigen eenmalige link! SimpleX Vergrendelen uitschakelen authentication reason + + Disable automatic message deletion? + Automatisch verwijderen van berichten uitschakelen? + alert title + + + Disable delete messages + Berichten verwijderen uitschakelen + alert button + Disable for all Uitschakelen voor iedereen @@ -2651,6 +2829,11 @@ Dit is uw eigen eenmalige link! Gebruik geen inloggegevens met proxy. No comment provided by engineer. + + Documents: + Documenten: + No comment provided by engineer. + Don't create address Maak geen adres aan @@ -2661,9 +2844,19 @@ Dit is uw eigen eenmalige link! Niet inschakelen No comment provided by engineer. + + Don't miss important messages. + Mis geen belangrijke berichten. + No comment provided by engineer. + Don't show again Niet meer weergeven + alert action + + + Done + Klaar No comment provided by engineer. @@ -2675,7 +2868,7 @@ Dit is uw eigen eenmalige link! Download Downloaden alert button - chat item action +chat item action Download errors @@ -2752,9 +2945,9 @@ Dit is uw eigen eenmalige link! Inschakelen (overschrijvingen behouden) No comment provided by engineer. - - Enable Flux - Flux inschakelen + + Enable Flux in Network & servers settings for better metadata privacy. + Schakel Flux in bij Netwerk- en serverinstellingen voor betere privacy van metagegevens. No comment provided by engineer. @@ -2770,7 +2963,7 @@ Dit is uw eigen eenmalige link! Enable automatic message deletion? Automatisch verwijderen van berichten aanzetten? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3090,11 @@ Dit is uw eigen eenmalige link! Opnieuw onderhandelen over de codering is mislukt. No comment provided by engineer. + + Encryption renegotiation in progress. + Er wordt opnieuw onderhandeld over de encryptie. + No comment provided by engineer. + Enter Passcode Voer toegangscode in @@ -2972,6 +3170,10 @@ Dit is uw eigen eenmalige link! Fout bij het accepteren van een contactverzoek No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Fout bij het toevoegen van leden @@ -3007,6 +3209,11 @@ Dit is uw eigen eenmalige link! Fout bij het overschakelen naar incognito! No comment provided by engineer. + + Error checking token status + Fout bij het controleren van de tokenstatus + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw. @@ -3027,6 +3234,11 @@ Dit is uw eigen eenmalige link! Fout bij maken van groep link No comment provided by engineer. + + Error creating list + Fout bij het aanmaken van de lijst + alert title + Error creating member contact Fout bij aanmaken contact @@ -3042,6 +3254,11 @@ Dit is uw eigen eenmalige link! Fout bij aanmaken van profiel! No comment provided by engineer. + + Error creating report + Fout bij het rapporteren + No comment provided by engineer. + Error decrypting file Fout bij het ontsleutelen van bestand @@ -3052,6 +3269,10 @@ Dit is uw eigen eenmalige link! Fout bij het verwijderen van de chat database No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Fout bij verwijderen gesprek! @@ -3152,10 +3373,20 @@ Dit is uw eigen eenmalige link! Fout bij opnieuw verbinden van servers No comment provided by engineer. + + Error registering for notifications + Fout bij registreren voor meldingen + alert title + Error removing member Fout bij verwijderen van lid - No comment provided by engineer. + alert title + + + Error reordering lists + Fout bij het opnieuw ordenen van lijsten + alert title Error resetting statistics @@ -3167,6 +3398,11 @@ Dit is uw eigen eenmalige link! Fout bij opslaan van ICE servers No comment provided by engineer. + + Error saving chat list + Fout bij het opslaan van chatlijst + alert title + Error saving group profile Fout bij opslaan van groep profiel @@ -3247,6 +3483,11 @@ Dit is uw eigen eenmalige link! Fout bij het synchroniseren van de verbinding No comment provided by engineer. + + Error testing server connection + Fout bij het testen van de serververbinding + No comment provided by engineer. + Error updating group link Fout bij bijwerken van groep link @@ -3290,7 +3531,9 @@ Dit is uw eigen eenmalige link! Error: %@ Fout: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3327,6 +3570,11 @@ Dit is uw eigen eenmalige link! Uitklappen chat item action + + Expired + Verlopen + token status text + Export database Database exporteren @@ -3367,20 +3615,35 @@ Dit is uw eigen eenmalige link! Snel en niet wachten tot de afzender online is! No comment provided by engineer. + + Faster deletion of groups. + Sneller verwijderen van groepen. + No comment provided by engineer. + Faster joining and more reliable messages. Snellere deelname en betrouwbaardere berichten. No comment provided by engineer. + + Faster sending messages. + Sneller verzenden van berichten. + No comment provided by engineer. + Favorite Favoriet swipe action + + Favorites + Favorieten + No comment provided by engineer. + File error Bestandsfout - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3652,13 @@ Dit is uw eigen eenmalige link! %@ alert message + + File is blocked by server operator: +%@. + Bestand is geblokkeerd door serveroperator: +%@. + file error text + File not found - most likely file was deleted or cancelled. Bestand niet gevonden - hoogstwaarschijnlijk is het bestand verwijderd of geannuleerd. @@ -3514,6 +3784,11 @@ Dit is uw eigen eenmalige link! Herstel wordt niet ondersteund door groepslid No comment provided by engineer. + + For all moderators + Voor alle moderators + No comment provided by engineer. + For chat profile %@: Voor chatprofiel %@: @@ -3529,6 +3804,11 @@ Dit is uw eigen eenmalige link! Als uw contactpersoon bijvoorbeeld berichten ontvangt via een SimpleX Chat-server, worden deze door uw app via een Flux-server verzonden. No comment provided by engineer. + + For me + Voor mij + No comment provided by engineer. + For private routing Voor privé-routering @@ -3653,6 +3933,11 @@ Fout: %2$@ GIF's en stickers No comment provided by engineer. + + Get notified when mentioned. + Ontvang een melding als u vermeld wordt. + No comment provided by engineer. + Good afternoon! Goedemiddag! @@ -3758,11 +4043,21 @@ Fout: %2$@ De groep wordt voor u verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + Groups + Groepen + No comment provided by engineer. + Help Help No comment provided by engineer. + + Help admins moderating their groups. + Help beheerders bij het modereren van hun groepen. + No comment provided by engineer. + Hidden Verborgen @@ -3823,6 +4118,11 @@ Fout: %2$@ Hoe het de privacy helpt No comment provided by engineer. + + How it works + Hoe het werkt + alert button + How to Hoe @@ -3965,6 +4265,16 @@ Binnenkort meer verbeteringen! Geluiden tijdens het bellen No comment provided by engineer. + + Inappropriate content + Ongepaste inhoud + report reason + + + Inappropriate profile + Ongepast profiel + report reason + Incognito Incognito @@ -4057,6 +4367,31 @@ Binnenkort meer verbeteringen! Interface kleuren No comment provided by engineer. + + Invalid + Ongeldig + token status text + + + Invalid (bad token) + Ongeldig (ongeldig token) + token status text + + + Invalid (expired) + Ongeldig (verlopen) + token status text + + + Invalid (unregistered) + Ongeldig (niet geregistreerd) + token status text + + + Invalid (wrong topic) + Ongeldig (verkeerd onderwerp) + token status text + Invalid QR code Ongeldige QR-code @@ -4185,7 +4520,7 @@ Binnenkort meer verbeteringen! Join - Word lid van + Word lid swipe action @@ -4330,6 +4665,21 @@ Dit is jouw link voor groep %@! Gelinkte desktops No comment provided by engineer. + + List + Lijst + swipe action + + + List name and emoji should be different for all lists. + De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn. + No comment provided by engineer. + + + List name... + Naam van lijst... + No comment provided by engineer. + Live message! Live bericht! @@ -4415,11 +4765,20 @@ Dit is jouw link voor groep %@! Lid No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Lid inactief item status text + + Member reports + Ledenrapporten + chat feature + Member role will be changed to "%@". All chat members will be notified. De rol van het lid wordt gewijzigd naar "%@". Alle chatleden worden op de hoogte gebracht. @@ -4445,6 +4804,10 @@ Dit is jouw link voor groep %@! Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Groepsleden kunnen bericht reacties toevoegen. @@ -4455,6 +4818,11 @@ Dit is jouw link voor groep %@! Groepsleden kunnen verzonden berichten onherroepelijk verwijderen. (24 uur) No comment provided by engineer. + + Members can report messsages to moderators. + Leden kunnen berichten melden bij moderators. + No comment provided by engineer. + Members can send SimpleX links. Groepsleden kunnen SimpleX-links verzenden. @@ -4480,6 +4848,11 @@ Dit is jouw link voor groep %@! Groepsleden kunnen spraak berichten verzenden. No comment provided by engineer. + + Mention members 👋 + Vermeld leden 👋 + No comment provided by engineer. + Menus Menu's @@ -4590,6 +4963,11 @@ Dit is jouw link voor groep %@! Berichten van %@ worden getoond! No comment provided by engineer. + + Messages in this chat will never be deleted. + Berichten in deze chat zullen nooit worden verwijderd. + alert message + Messages received Berichten ontvangen @@ -4690,6 +5068,11 @@ Dit is jouw link voor groep %@! Gemodereerd op: %@ copied message info + + More + Meer + swipe action + More improvements are coming soon! Meer verbeteringen volgen snel! @@ -4718,7 +5101,12 @@ Dit is jouw link voor groep %@! Mute Dempen - swipe action + notification label action + + + Mute all + Alles dempen + notification label action Muted when inactive! @@ -4770,6 +5158,11 @@ Dit is jouw link voor groep %@! Netwerk status No comment provided by engineer. + + New + Nieuw + token status text + New Passcode Nieuwe toegangscode @@ -4835,6 +5228,10 @@ Dit is jouw link voor groep %@! Nieuwe leden rol No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message nieuw bericht @@ -4860,6 +5257,25 @@ Dit is jouw link voor groep %@! Geen app wachtwoord Authentication unavailable + + No chats + Geen chats + No comment provided by engineer. + + + No chats found + Geen chats gevonden + No comment provided by engineer. + + + No chats in list %@ + Geen chats in lijst %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Geen contacten geselecteerd @@ -4910,6 +5326,11 @@ Dit is jouw link voor groep %@! Geen media- en bestandsservers. servers error + + No message + Geen bericht + No comment provided by engineer. + No message servers. Geen berichtenservers. @@ -4965,6 +5386,16 @@ Dit is jouw link voor groep %@! Geen servers om bestanden te verzenden. servers error + + No token! + Geen token! + alert title + + + No unread chats + Geen ongelezen chats + No comment provided by engineer. + No user identifiers. Geen gebruikers-ID's. @@ -4975,6 +5406,11 @@ Dit is jouw link voor groep %@! Niet compatibel! No comment provided by engineer. + + Notes + Notities + No comment provided by engineer. + Nothing selected Niets geselecteerd @@ -4995,11 +5431,21 @@ Dit is jouw link voor groep %@! Meldingen zijn uitgeschakeld! No comment provided by engineer. + + Notifications error + Meldingsfout + alert title + Notifications privacy Privacy van meldingen No comment provided by engineer. + + Notifications status + Meldingsstatus + alert title + Now admins can: - delete members' messages. @@ -5022,7 +5468,8 @@ Dit is jouw link voor groep %@! Ok OK - alert button + alert action +alert button Old database @@ -5083,6 +5530,16 @@ Vereist het inschakelen van VPN. Alleen groep eigenaren kunnen spraak berichten inschakelen. No comment provided by engineer. + + Only sender and moderators see it + Alleen de verzender en moderators zien het + No comment provided by engineer. + + + Only you and moderators see it + Alleen jij en moderators zien het + No comment provided by engineer. + Only you can add message reactions. Alleen jij kunt bericht reacties toevoegen. @@ -5136,7 +5593,7 @@ Vereist het inschakelen van VPN. Open Open - No comment provided by engineer. + alert action Open Settings @@ -5168,6 +5625,10 @@ Vereist het inschakelen van VPN. Open groep No comment provided by engineer. + + Open link? + alert title + Open migration to another device Open de migratie naar een ander apparaat @@ -5218,6 +5679,11 @@ Vereist het inschakelen van VPN. Of om privé te delen No comment provided by engineer. + + Organize chats into lists + Organiseer chats in lijsten + No comment provided by engineer. + Other Ander @@ -5409,6 +5875,25 @@ Fout: %@ Bewaar het wachtwoord veilig, u kunt deze NIET wijzigen als u het kwijtraakt. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Probeer meldingen uit en weer in te schakelen. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + Wacht tot de tokenactivering voltooid is. + token info + + + Please wait for token to be registered. + Wacht tot het token is geregistreerd. + token info + Polish interface Poolse interface @@ -5459,16 +5944,31 @@ Fout: %@ Privacy voor uw klanten. No comment provided by engineer. + + Privacy policy and conditions of use. + Privacybeleid en gebruiksvoorwaarden. + No comment provided by engineer. + Privacy redefined Privacy opnieuw gedefinieerd No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders. + No comment provided by engineer. + Private filenames Privé bestandsnamen No comment provided by engineer. + + Private media file names. + Namen van persoonlijke mediabestanden. + No comment provided by engineer. + Private message routing Routering van privéberichten @@ -5544,6 +6044,11 @@ Fout: %@ Berichten reacties verbieden. No comment provided by engineer. + + Prohibit reporting messages to moderators. + Het melden van berichten aan moderators is niet toegestaan. + No comment provided by engineer. + Prohibit sending SimpleX links. Verbied het verzenden van SimpleX-links @@ -5801,11 +6306,26 @@ Schakel dit in in *Netwerk en servers*-instellingen. Verminderd batterijgebruik No comment provided by engineer. + + Register + Register + No comment provided by engineer. + + + Register notification token? + Meldingstoken registreren? + token info + + + Registered + Geregistreerd + token status text + Reject Afwijzen reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5817,6 +6337,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Contactverzoek afwijzen No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relay server wordt alleen gebruikt als dat nodig is. Een andere partij kan uw IP-adres zien. @@ -5902,6 +6426,60 @@ Schakel dit in in *Netwerk en servers*-instellingen. Antwoord chat item action + + Report + rapporteren + chat item action + + + Report content: only group moderators will see it. + Inhoud melden: alleen groepsmoderators kunnen dit zien. + report reason + + + Report member profile: only group moderators will see it. + Rapporteer ledenprofiel: alleen groepsmoderators kunnen dit zien. + report reason + + + Report other: only group moderators will see it. + Anders melden: alleen groepsmoderators kunnen het zien. + report reason + + + Report reason? + Reden melding? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + Spam melden: alleen groepsmoderators kunnen het zien. + report reason + + + Report violation: only group moderators will see it. + Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. + report reason + + + Report: %@ + rapporteer: %@ + report in notification + + + Reporting messages to moderators is prohibited. + Het is niet toegestaan om berichten aan moderators te melden. + No comment provided by engineer. + + + Reports + Rapporten + No comment provided by engineer. + Required Vereist @@ -5992,10 +6570,13 @@ Schakel dit in in *Netwerk en servers*-instellingen. Voorwaarden bekijken No comment provided by engineer. - - Review later - Later beoordelen - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -6046,13 +6627,17 @@ Schakel dit in in *Netwerk en servers*-instellingen. Save Opslaan alert button - chat item action +chat item action Save (and notify contacts) Bewaar (en informeer contacten) alert button + + Save admission settings? + alert title + Save and notify contact Opslaan en Contact melden @@ -6078,9 +6663,14 @@ Schakel dit in in *Netwerk en servers*-instellingen. Groep profiel opslaan No comment provided by engineer. + + Save list + Lijst opslaan + No comment provided by engineer. + Save passphrase and open chat - Bewaar het wachtwoord en open je chats + Wachtwoord opslaan en open je chats No comment provided by engineer. @@ -6318,6 +6908,11 @@ Schakel dit in in *Netwerk en servers*-instellingen. Meldingen verzenden No comment provided by engineer. + + Send private reports + Rapporteer privé + No comment provided by engineer. + Send questions and ideas Stuur vragen en ideeën @@ -6533,6 +7128,11 @@ Schakel dit in in *Netwerk en servers*-instellingen. Stel 1 dag in No comment provided by engineer. + + Set chat name… + Stel chatnaam in… + No comment provided by engineer. + Set contact name… Contactnaam instellen… @@ -6553,6 +7153,15 @@ Schakel dit in in *Netwerk en servers*-instellingen. Stel het in in plaats van systeemverificatie. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + Stel de berichtvervaldatum in chats in. + No comment provided by engineer. + Set passcode Toegangscode instellen @@ -6597,7 +7206,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Share Deel alert action - chat item action +chat item action Share 1-time link @@ -6659,6 +7268,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Delen met contacten No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Toon QR-code @@ -6759,6 +7372,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. SimpleX adres of eenmalige link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX contact adres @@ -6861,6 +7478,12 @@ Schakel dit in in *Netwerk en servers*-instellingen. Iemand notification title + + Spam + Spam + blocking reason +report reason + Square, circle, or anything in between. Vierkant, cirkel of iets daartussenin. @@ -6946,6 +7569,11 @@ Schakel dit in in *Netwerk en servers*-instellingen. Chat stoppen No comment provided by engineer. + + Storage + Opslag + No comment provided by engineer. + Strong Krachtig @@ -6953,7 +7581,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Submit - Indienen + Bevestigen No comment provided by engineer. @@ -7006,6 +7634,11 @@ Schakel dit in in *Netwerk en servers*-instellingen. Timeout van TCP-verbinding No comment provided by engineer. + + TCP port for messaging + TCP-poort voor berichtenuitwisseling + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7074,13 +7707,18 @@ Schakel dit in in *Netwerk en servers*-instellingen. Temporary file error Tijdelijke bestandsfout - No comment provided by engineer. + file error alert title Test failed at step %@. Test mislukt bij stap %@. server test failure + + Test notifications + Testmeldingen + No comment provided by engineer. + Test server Server test @@ -7203,21 +7841,11 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De oude database is niet verwijderd tijdens de migratie, deze kan worden verwijderd. No comment provided by engineer. - - The profile is only shared with your contacts. - Het profiel wordt alleen gedeeld met uw contacten. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Dezelfde voorwaarden gelden voor operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Dezelfde voorwaarden gelden voor operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! De tweede vooraf ingestelde operator in de app! @@ -7283,6 +7911,11 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze actie kan niet ongedaan worden gemaakt, de berichten die eerder zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. Het kan enkele minuten duren. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Deze actie kan niet ongedaan worden gemaakt. De berichten die eerder in deze chat zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Deze actie kan niet ongedaan worden gemaakt. Uw profiel, contacten, berichten en bestanden gaan definitief verloren. @@ -7300,7 +7933,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This device name - Deze apparaatnaam + Naam van dit apparaat No comment provided by engineer. @@ -7328,11 +7961,20 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Dit is uw eigen eenmalige link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Deze link is gebruikt met een ander mobiel apparaat. Maak een nieuwe link op de desktop. No comment provided by engineer. + + This message was deleted or not received yet. + Dit bericht is verwijderd of nog niet ontvangen. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Deze instelling is van toepassing op berichten in je huidige chatprofiel **%@**. @@ -7445,6 +8087,11 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Schakel incognito in tijdens het verbinden. No comment provided by engineer. + + Token status: %@. + Tokenstatus: %@. + token status + Toolbar opacity De transparantie van de werkbalk @@ -7610,13 +8257,17 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Unmute Dempen opheffen - swipe action + notification label action Unread Ongelezen swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Er worden maximaal 100 laatste berichten naar nieuwe leden verzonden. @@ -7642,6 +8293,11 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Instellingen actualiseren? No comment provided by engineer. + + Updated conditions + Bijgewerkte voorwaarden + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Door de instellingen bij te werken, wordt de client opnieuw verbonden met alle servers. @@ -7702,6 +8358,15 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak SimpleX Chat servers gebruiken? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + Gebruik TCP-poort %@ als er geen poort is opgegeven. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Gebruik chat @@ -7767,6 +8432,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Gebruik de app tijdens het gesprek. @@ -7777,6 +8446,11 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik de app met één hand. No comment provided by engineer. + + Use web port + Gebruik een webpoort + No comment provided by engineer. + User selection Gebruikersselectie @@ -7789,7 +8463,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Using SimpleX Chat servers. - SimpleX Chat servers gebruiken. + Gebruik SimpleX Chat servers. No comment provided by engineer. @@ -8149,11 +8823,6 @@ Deelnameverzoek herhalen? U kunt dit wijzigen in de instellingen onder uiterlijk. No comment provided by engineer. - - You can configure operators in Network & servers settings. - U kunt operators configureren in Netwerk- en serverinstellingen. - No comment provided by engineer. - You can configure servers via settings. U kunt servers configureren via instellingen. @@ -8244,6 +8913,10 @@ Deelnameverzoek herhalen? U kunt de uitnodigingslink opnieuw bekijken in de verbindingsdetails. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Je kunt geen berichten versturen! @@ -8326,6 +8999,11 @@ Verbindingsverzoek herhalen? Je hebt een groep uitnodiging verzonden No comment provided by engineer. + + You should receive notifications. + U zou meldingen moeten ontvangen. + token info + You will be connected to group when the group host's device is online, please wait or check later! Je wordt verbonden met de groep wanneer het apparaat van de groep host online is, even geduld a.u.b. of controleer het later! @@ -8391,11 +9069,6 @@ Verbindingsverzoek herhalen? Uw ICE servers No comment provided by engineer. - - Your SMP servers - Uw SMP servers - No comment provided by engineer. - Your SimpleX address Uw SimpleX adres @@ -8481,6 +9154,11 @@ Verbindingsverzoek herhalen? Uw profiel **%@** wordt gedeeld. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Het profiel wordt alleen gedeeld met uw contacten. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Uw profiel wordt op uw apparaat opgeslagen en alleen gedeeld met uw contacten. SimpleX servers kunnen uw profiel niet zien. @@ -8491,11 +9169,6 @@ Verbindingsverzoek herhalen? Je profiel is gewijzigd. Als je het opslaat, wordt het bijgewerkte profiel naar al je contacten verzonden. alert message - - Your profile, contacts and delivered messages are stored on your device. - Uw profiel, contacten en afgeleverde berichten worden op uw apparaat opgeslagen. - No comment provided by engineer. - Your random profile Je willekeurige profiel @@ -8546,6 +9219,10 @@ Verbindingsverzoek herhalen? hier boven, kies dan: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call geaccepteerde oproep @@ -8556,6 +9233,10 @@ Verbindingsverzoek herhalen? geaccepteerde uitnodiging chat list item title + + accepted you + rcv group event chat item + admin Beheerder @@ -8576,6 +9257,10 @@ Verbindingsverzoek herhalen? versleuteling overeenkomen… chat item text + + all + member criteria value + all members alle leden @@ -8591,6 +9276,11 @@ Verbindingsverzoek herhalen? en %lld andere gebeurtenissen No comment provided by engineer. + + archived report + gearchiveerd rapport + No comment provided by engineer. + attempts pogingen @@ -8623,13 +9313,14 @@ Verbindingsverzoek herhalen? blocked %@ - geblokkeerd %@ + blokkeerde %@ rcv group event chat item blocked by admin geblokkeerd door beheerder - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8656,6 +9347,10 @@ Verbindingsverzoek herhalen? bellen… call status + + can't send messages + No comment provided by engineer. + cancelled %@ geannuleerd %@ @@ -8761,6 +9456,14 @@ Verbindingsverzoek herhalen? contactpersoon %1$@ gewijzigd in %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption contact heeft e2e-codering @@ -8771,6 +9474,10 @@ Verbindingsverzoek herhalen? contact heeft geen e2e versleuteling No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator creator @@ -8799,7 +9506,8 @@ Verbindingsverzoek herhalen? default (%@) standaard (%@) - pref value + delete after time +pref value default (no) @@ -8926,21 +9634,11 @@ Verbindingsverzoek herhalen? fout No comment provided by engineer. - - event happened - gebeurtenis gebeurd - No comment provided by engineer. - expired verlopen No comment provided by engineer. - - for better metadata privacy. - voor betere privacy van metagegevens. - No comment provided by engineer. - forwarded doorgestuurd @@ -8951,6 +9649,10 @@ Verbindingsverzoek herhalen? groep verwijderd No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated groep profiel bijgewerkt @@ -9076,6 +9778,10 @@ Verbindingsverzoek herhalen? is toegetreden rcv group event chat item + + member has old version + No comment provided by engineer. + message bericht @@ -9106,20 +9812,20 @@ Verbindingsverzoek herhalen? gemodereerd door %@ marked deleted chat item preview text + + moderator + moderator + member role + months maanden time unit - - mute - dempen - No comment provided by engineer. - never nooit - No comment provided by engineer. + delete after time new message @@ -9141,6 +9847,10 @@ Verbindingsverzoek herhalen? geen tekst copied message info in history + + not synchronized + No comment provided by engineer. + observer Waarnemer @@ -9150,8 +9860,9 @@ Verbindingsverzoek herhalen? off uit enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9193,6 +9904,20 @@ Verbindingsverzoek herhalen? peer-to-peer No comment provided by engineer. + + pending + In behandeling + No comment provided by engineer. + + + pending approval + in afwachting van goedkeuring + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption quantum bestendige e2e-codering @@ -9208,6 +9933,11 @@ Verbindingsverzoek herhalen? bevestiging ontvangen… No comment provided by engineer. + + rejected + afgewezen + No comment provided by engineer. + rejected call geweigerde oproep @@ -9228,6 +9958,10 @@ Verbindingsverzoek herhalen? contactadres verwijderd profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profielfoto verwijderd @@ -9238,11 +9972,23 @@ Verbindingsverzoek herhalen? heeft je verwijderd rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect - gevraagd om verbinding te maken + verzocht om verbinding te maken chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved opgeslagen @@ -9299,7 +10045,7 @@ laatst ontvangen bericht: %2$@ set new profile picture - nieuwe profielfoto instellen + nieuwe profielfoto profile update event chat item @@ -9342,11 +10088,6 @@ laatst ontvangen bericht: %2$@ onbekende status No comment provided by engineer. - - unmute - dempen opheffen - No comment provided by engineer. - unprotected onbeschermd @@ -9437,6 +10178,10 @@ laatst ontvangen bericht: %2$@ jij No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group je bent uitgenodigd voor de groep @@ -9511,7 +10256,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -9548,7 +10293,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -9570,7 +10315,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -9578,6 +10323,10 @@ laatst ontvangen bericht: %2$@ ‐%d nieuwe gebeurtenissen notification body + + From %d chat(s) + notification body + From: %@ Van: %@ @@ -9593,16 +10342,11 @@ laatst ontvangen bericht: %2$@ Nieuwe berichten notification - - New messages in %d chats - Nieuwe berichten in %d chats - notification body -
- +
@@ -9624,7 +10368,7 @@ laatst ontvangen bericht: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/contents.json b/apps/ios/SimpleX Localizations/nl.xcloc/contents.json index 4c631c367e..4b8d468de2 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/nl.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "nl", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 8cfdf56f66..3255489efd 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (można skopiować) @@ -114,10 +102,12 @@ %@ server + %@ serwer No comment provided by engineer. %@ servers + %@ serwery/ów No comment provided by engineer. @@ -200,6 +190,11 @@ %d sek time interval + + %d seconds(s) + %d sekundach + delete after time + %d skipped message(s) %d pominięte wiadomość(i) @@ -270,11 +265,6 @@ %lld nowe języki interfejsu No comment provided by engineer. - - %lld second(s) - %lld sekund(y) - No comment provided by engineer. - %lld seconds %lld sekund @@ -325,11 +315,6 @@ %u pominiętych wiadomości. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nowy) @@ -340,11 +325,6 @@ (to urządzenie v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Dodaj kontakt**: aby utworzyć nowy link z zaproszeniem lub połączyć się za pomocą otrzymanego linku. @@ -382,6 +362,7 @@ **Scan / Paste link**: to connect via a link you received. + **Zeskanuj / Wklej link**: aby połączyć się za pomocą otrzymanego linku. No comment provided by engineer. @@ -409,11 +390,6 @@ \*pogrubiony* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -450,11 +426,6 @@ - historia edycji. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sek @@ -468,7 +439,8 @@ 1 day 1 dzień - time interval + delete after time +time interval 1 hour @@ -483,19 +455,28 @@ 1 month 1 miesiąc - time interval + delete after time +time interval 1 week 1 tydzień - time interval + delete after time +time interval + + + 1 year + 1 roku + delete after time 1-time link + link jednorazowy No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Link jednorazowy może być użyty *tylko z jednym kontaktem* - udostępnij go osobiście lub przez dowolny komunikator. No comment provided by engineer. @@ -513,11 +494,6 @@ 30 sekund No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -574,6 +550,7 @@ About operators + O operatorach No comment provided by engineer. @@ -585,11 +562,20 @@ Accept Akceptuj accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions + Zaakceptuj warunki No comment provided by engineer. @@ -606,10 +592,15 @@ Accept incognito Akceptuj incognito accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions + Zaakceptowano warunki No comment provided by engineer. @@ -622,6 +613,11 @@ Błędy potwierdzenia No comment provided by engineer. + + Active + Aktywne + token status text + Active connections Aktywne połączenia @@ -634,6 +630,12 @@ Add friends + Dodaj znajomych + No comment provided by engineer. + + + Add list + Dodaj listę No comment provided by engineer. @@ -653,6 +655,7 @@ Add team members + Dodaj członków zespołu No comment provided by engineer. @@ -660,6 +663,11 @@ Dodaj do innego urządzenia No comment provided by engineer. + + Add to list + Dodaj do listy + No comment provided by engineer. + Add welcome message Dodaj wiadomość powitalną @@ -667,14 +675,17 @@ Add your team members to the conversations. + Dodaj członków zespołu do konwersacji. No comment provided by engineer. Added media & file servers + Dodano serwery multimediów i plików No comment provided by engineer. Added message servers + Dodano serwery wiadomości No comment provided by engineer. @@ -704,10 +715,12 @@ Address or 1-time link? + Adres czy jednorazowy link? No comment provided by engineer. Address settings + Ustawienia adresu No comment provided by engineer. @@ -730,6 +743,11 @@ Zaawansowane ustawienia No comment provided by engineer. + + All + Wszystko + No comment provided by engineer. + All app data is deleted. Wszystkie dane aplikacji są usunięte. @@ -740,6 +758,11 @@ Wszystkie czaty i wiadomości zostaną usunięte - nie można tego cofnąć! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Wszystkie rozmowy zostaną usunięte z listy %@, a lista usunięta. + alert message + All data is erased when it is entered. Wszystkie dane są usuwane po jego wprowadzeniu. @@ -757,6 +780,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich. No comment provided by engineer. @@ -779,6 +803,15 @@ Wszystkie profile profile dropdown + + All reports will be archived for you. + Wszystkie raporty zostaną dla Ciebie zarchiwizowane. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Wszystkie Twoje kontakty pozostaną połączone. @@ -854,6 +887,11 @@ Zezwól na nieodwracalne usunięcie wysłanych wiadomości. (24 godziny) No comment provided by engineer. + + Allow to report messsages to moderators. + Zezwól na zgłaszanie wiadomości moderatorom. + No comment provided by engineer. + Allow to send SimpleX links. Zezwól na wysyłanie linków SimpleX. @@ -934,6 +972,11 @@ Tworzony jest pusty profil czatu o podanej nazwie, a aplikacja otwiera się jak zwykle. No comment provided by engineer. + + Another reason + Inny powód + report reason + Answer call Odbierz połączenie @@ -959,6 +1002,11 @@ Aplikacja szyfruje nowe lokalne pliki (bez filmów). No comment provided by engineer. + + App group: + Grupa aplikacji: + No comment provided by engineer. + App icon Ikona aplikacji @@ -1004,6 +1052,21 @@ Zastosuj dla No comment provided by engineer. + + Archive + Archiwizuj + No comment provided by engineer. + + + Archive %lld reports? + Archiwizować %lld reports? + No comment provided by engineer. + + + Archive all reports? + Archiwizować wszystkie zgłoszenia? + No comment provided by engineer. + Archive and upload Archiwizuj i prześlij @@ -1014,6 +1077,21 @@ Archiwizuj kontakty aby porozmawiać później. No comment provided by engineer. + + Archive report + Archiwizuj zgłoszenie + No comment provided by engineer. + + + Archive report? + Archiwizować zgłoszenie? + No comment provided by engineer. + + + Archive reports + Archiwizuj zgłoszenia + swipe action + Archived contacts Zarchiwizowane kontakty @@ -1116,6 +1194,7 @@ Better calls + Lepsze połączenia No comment provided by engineer. @@ -1123,8 +1202,13 @@ Lepsze grupy No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. + Lepsze daty wiadomości. No comment provided by engineer. @@ -1139,14 +1223,21 @@ Better notifications + Lepsze powiadomienia + No comment provided by engineer. + + + Better privacy and security No comment provided by engineer. Better security ✅ + Lepsze zabezpieczenia ✅ No comment provided by engineer. Better user experience + Lepszy interfejs użytkownika No comment provided by engineer. @@ -1231,10 +1322,17 @@ Business address + Adres firmowy No comment provided by engineer. Business chats + Czaty biznesowe + No comment provided by engineer. + + + Businesses + Firmy No comment provided by engineer. @@ -1242,6 +1340,12 @@ Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Połączenie już zakończone! @@ -1291,7 +1395,7 @@ Cancel Anuluj alert action - alert button +alert button Cancel migration @@ -1328,8 +1432,13 @@ Zmień No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Zmień profil czatu authentication reason @@ -1376,18 +1485,21 @@ Change self-destruct passcode Zmień pin samozniszczenia authentication reason - set passcode view +set passcode view Chat + Czat No comment provided by engineer. Chat already exists + Czat już istnieje No comment provided by engineer. Chat already exists! + Czat już istnieje! No comment provided by engineer. @@ -1412,12 +1524,12 @@ Chat database exported - Wyeksportowano bazę danych czatu + Wyeksportowano bazę danych czatów No comment provided by engineer. Chat database imported - Zaimportowano bazę danych czatu + Zaimportowano bazę danych czatów No comment provided by engineer. @@ -1467,10 +1579,20 @@ Chat will be deleted for all members - this cannot be undone! + Czat zostanie usunięty dla wszystkich członków – tej operacji nie można cofnąć! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć! + No comment provided by engineer. + + + Chat with admins + chat toolbar + + + Chat with member No comment provided by engineer. @@ -1478,12 +1600,18 @@ Czaty No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. + Sprawdzaj wiadomości co 20 min. No comment provided by engineer. Check messages when allowed. + Sprawdź wiadomości, gdy będzie to dopuszczone. No comment provided by engineer. @@ -1541,6 +1669,14 @@ Wyczyścić rozmowę? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Wyczyścić prywatne notatki? @@ -1561,6 +1697,10 @@ Tryb koloru No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Porównaj plik @@ -1578,26 +1718,22 @@ Conditions accepted on: %@. + Warunki zaakceptowane dnia: %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Warunki zostały zaakceptowane przez operatora(-ów): **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**. No comment provided by engineer. Conditions of use - No comment provided by engineer. - - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. + Warunki użytkowania No comment provided by engineer. @@ -1617,6 +1753,10 @@ Skonfiguruj serwery ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Potwierdź @@ -1667,6 +1807,10 @@ Potwierdź wgranie No comment provided by engineer. + + Confirmed + token status text + Connect Połącz @@ -1786,6 +1930,10 @@ To jest twój jednorazowy link! Stan połączenia i serwerów. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Błąd połączenia @@ -1796,6 +1944,15 @@ To jest twój jednorazowy link! Błąd połączenia (UWIERZYTELNIANIE) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Powiadomienia o połączeniu @@ -1806,6 +1963,10 @@ To jest twój jednorazowy link! Prośba o połączenie wysłana! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1885,6 +2046,10 @@ To jest twój jednorazowy link! Kontakty mogą oznaczać wiadomości do usunięcia; będziesz mógł je zobaczyć. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Kontynuuj @@ -1959,6 +2124,10 @@ To jest twój jednorazowy link! Utwórz link No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Utwórz nowy profil w [aplikacji desktopowej](https://simplex.chat/downloads/). 💻 @@ -2174,8 +2343,7 @@ To jest twój jednorazowy link! Delete Usuń alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2216,6 +2384,10 @@ To jest twój jednorazowy link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Usuń profil czatu @@ -2226,6 +2398,10 @@ To jest twój jednorazowy link! Usunąć profil czatu? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2305,6 +2481,10 @@ To jest twój jednorazowy link! Usunąć link? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Usunąć wiadomość członka? @@ -2318,7 +2498,7 @@ To jest twój jednorazowy link! Delete messages Usuń wiadomości - No comment provided by engineer. + alert button Delete messages after @@ -2354,6 +2534,10 @@ To jest twój jednorazowy link! Usuń kolejkę server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Usuń do 20 wiadomości na raz. @@ -2512,6 +2696,14 @@ To jest twój jednorazowy link! Wyłącz blokadę SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Wyłącz dla wszystkich @@ -2602,6 +2794,10 @@ To jest twój jednorazowy link! Nie używaj danych logowania do proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Nie twórz adresu @@ -2612,9 +2808,17 @@ To jest twój jednorazowy link! Nie włączaj No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Nie pokazuj ponownie + alert action + + + Done No comment provided by engineer. @@ -2626,7 +2830,7 @@ To jest twój jednorazowy link! Download Pobierz alert button - chat item action +chat item action Download errors @@ -2702,8 +2906,8 @@ To jest twój jednorazowy link! Włącz (zachowaj nadpisania) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2719,7 +2923,7 @@ To jest twój jednorazowy link! Enable automatic message deletion? Czy włączyć automatyczne usuwanie wiadomości? - No comment provided by engineer. + alert title Enable camera access @@ -2846,6 +3050,10 @@ To jest twój jednorazowy link! Renegocjacja szyfrowania nie powiodła się. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Wprowadź Pin @@ -2920,6 +3128,10 @@ To jest twój jednorazowy link! Błąd przyjmowania prośby o kontakt No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Błąd dodawania członka(ów) @@ -2954,6 +3166,10 @@ To jest twój jednorazowy link! Błąd zmiany na incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później. @@ -2974,6 +3190,10 @@ To jest twój jednorazowy link! Błąd tworzenia linku grupy No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Błąd tworzenia kontaktu członka @@ -2989,6 +3209,10 @@ To jest twój jednorazowy link! Błąd tworzenia profilu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Błąd odszyfrowania pliku @@ -2999,6 +3223,10 @@ To jest twój jednorazowy link! Błąd usuwania bazy danych czatu No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Błąd usuwania czatu! @@ -3098,10 +3326,18 @@ To jest twój jednorazowy link! Błąd ponownego łączenia serwerów No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Błąd usuwania członka - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -3113,6 +3349,10 @@ To jest twój jednorazowy link! Błąd zapisu serwerów ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Błąd zapisu profilu grupy @@ -3192,6 +3432,10 @@ To jest twój jednorazowy link! Błąd synchronizacji połączenia No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Błąd aktualizacji linku grupy @@ -3234,7 +3478,9 @@ To jest twój jednorazowy link! Error: %@ Błąd: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3270,6 +3516,10 @@ To jest twój jednorazowy link! Rozszerz chat item action + + Expired + token status text + Export database Eksportuj bazę danych @@ -3310,20 +3560,32 @@ To jest twój jednorazowy link! Szybko i bez czekania aż nadawca będzie online! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Szybsze dołączenie i bardziej niezawodne wiadomości. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Ulubione swipe action + + Favorites + No comment provided by engineer. + File error Błąd pliku - No comment provided by engineer. + file error alert title File errors: @@ -3332,6 +3594,11 @@ To jest twój jednorazowy link! %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. Nie odnaleziono pliku - najprawdopodobniej plik został usunięty lub anulowany. @@ -3457,6 +3724,10 @@ To jest twój jednorazowy link! Naprawa nie jest obsługiwana przez członka grupy No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3470,6 +3741,10 @@ To jest twój jednorazowy link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -3591,6 +3866,10 @@ Błąd: %2$@ GIF-y i naklejki No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Dzień dobry! @@ -3696,11 +3975,19 @@ Błąd: %2$@ Grupa zostanie usunięta dla Ciebie - nie można tego cofnąć! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Pomoc No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Ukryte @@ -3759,6 +4046,10 @@ Błąd: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Jak @@ -3899,6 +4190,14 @@ More improvements are coming soon! Dźwięki w rozmowie No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -3991,6 +4290,26 @@ More improvements are coming soon! Kolory interfejsu No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Nieprawidłowy kod QR @@ -4261,6 +4580,18 @@ To jest twój link do grupy %@! Połączone komputery No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Wiadomość na żywo! @@ -4346,11 +4677,19 @@ To jest twój link do grupy %@! Członek No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Członek nieaktywny item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4374,6 +4713,10 @@ To jest twój link do grupy %@! Członek zostanie usunięty z grupy - nie można tego cofnąć! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Członkowie grupy mogą dodawać reakcje wiadomości. @@ -4384,6 +4727,10 @@ To jest twój link do grupy %@! Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Członkowie grupy mogą wysyłać linki SimpleX. @@ -4409,6 +4756,10 @@ To jest twój link do grupy %@! Członkowie grupy mogą wysyłać wiadomości głosowe. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menu @@ -4519,6 +4870,10 @@ To jest twój link do grupy %@! Wiadomości od %@ zostaną pokazane! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Otrzymane wiadomości @@ -4619,6 +4974,10 @@ To jest twój link do grupy %@! Moderowany o: %@ copied message info + + More + swipe action + More improvements are coming soon! Więcej ulepszeń już wkrótce! @@ -4646,7 +5005,11 @@ To jest twój link do grupy %@! Mute Wycisz - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4696,6 +5059,10 @@ To jest twój link do grupy %@! Status sieci No comment provided by engineer. + + New + token status text + New Passcode Nowy Pin @@ -4760,6 +5127,10 @@ To jest twój link do grupy %@! Nowa rola członka No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Nowa wiadomość @@ -4784,6 +5155,22 @@ To jest twój link do grupy %@! Brak hasła aplikacji Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Nie wybrano kontaktów @@ -4833,6 +5220,10 @@ To jest twój link do grupy %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -4883,6 +5274,14 @@ To jest twój link do grupy %@! No servers to send files. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. Brak identyfikatorów użytkownika. @@ -4893,6 +5292,10 @@ To jest twój link do grupy %@! Nie kompatybilny! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nic nie jest zaznaczone @@ -4913,10 +5316,18 @@ To jest twój link do grupy %@! Powiadomienia są wyłączone! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4939,7 +5350,8 @@ To jest twój link do grupy %@! Ok Ok - alert button + alert action +alert button Old database @@ -4999,6 +5411,14 @@ Wymaga włączenia VPN. Tylko właściciele grup mogą włączyć wiadomości głosowe. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Tylko Ty możesz dodawać reakcje wiadomości. @@ -5052,7 +5472,7 @@ Wymaga włączenia VPN. Open Otwórz - No comment provided by engineer. + alert action Open Settings @@ -5082,6 +5502,10 @@ Wymaga włączenia VPN. Grupa otwarta No comment provided by engineer. + + Open link? + alert title + Open migration to another device Otwórz migrację na innym urządzeniu @@ -5128,6 +5552,10 @@ Wymaga włączenia VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Inne @@ -5319,6 +5747,22 @@ Błąd: %@ Przechowuj kod dostępu w bezpieczny sposób, w przypadku jego utraty NIE będzie można go zmienić. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Polski interfejs @@ -5367,16 +5811,28 @@ Błąd: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Redefinicja prywatności No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Prywatne nazwy plików No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Trasowanie prywatnych wiadomości @@ -5452,6 +5908,10 @@ Błąd: %@ Zabroń reakcje wiadomości. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Zabroń wysyłania linków SimpleX. @@ -5709,11 +6169,23 @@ Włącz w ustawianiach *Sieć i serwery* . Zmniejszone zużycie baterii No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Odrzuć reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5725,6 +6197,10 @@ Włącz w ustawianiach *Sieć i serwery* . Odrzuć prośbę kontaktu No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Serwer przekaźnikowy jest używany tylko w razie potrzeby. Inna strona może obserwować Twój adres IP. @@ -5810,6 +6286,50 @@ Włącz w ustawianiach *Sieć i serwery* . Odpowiedz chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required Wymagane @@ -5899,9 +6419,13 @@ Włącz w ustawianiach *Sieć i serwery* . Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5952,13 +6476,17 @@ Włącz w ustawianiach *Sieć i serwery* . Save Zapisz alert button - chat item action +chat item action Save (and notify contacts) Zapisz (i powiadom kontakty) alert button + + Save admission settings? + alert title + Save and notify contact Zapisz i powiadom kontakt @@ -5984,6 +6512,10 @@ Włącz w ustawianiach *Sieć i serwery* . Zapisz profil grupy No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Zapisz hasło i otwórz czat @@ -6224,6 +6756,10 @@ Włącz w ustawianiach *Sieć i serwery* . Wyślij powiadomienia No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Wyślij pytania i pomysły @@ -6435,6 +6971,10 @@ Włącz w ustawianiach *Sieć i serwery* . Ustaw 1 dzień No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Ustaw nazwę kontaktu… @@ -6455,6 +6995,14 @@ Włącz w ustawianiach *Sieć i serwery* . Ustaw go zamiast uwierzytelniania systemowego. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Ustaw pin @@ -6499,7 +7047,7 @@ Włącz w ustawianiach *Sieć i serwery* . Share Udostępnij alert action - chat item action +chat item action Share 1-time link @@ -6558,6 +7106,10 @@ Włącz w ustawianiach *Sieć i serwery* . Udostępnij kontaktom No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Pokaż kod QR @@ -6655,6 +7207,10 @@ Włącz w ustawianiach *Sieć i serwery* . SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Adres kontaktowy SimpleX @@ -6754,6 +7310,11 @@ Włącz w ustawianiach *Sieć i serwery* . Ktoś notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. Kwadrat, okrąg lub cokolwiek pomiędzy. @@ -6839,6 +7400,10 @@ Włącz w ustawianiach *Sieć i serwery* . Zatrzymywanie czatu No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Silne @@ -6897,6 +7462,10 @@ Włącz w ustawianiach *Sieć i serwery* . Limit czasu połączenia TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6964,13 +7533,17 @@ Włącz w ustawianiach *Sieć i serwery* . Temporary file error Tymczasowy błąd pliku - No comment provided by engineer. + file error alert title Test failed at step %@. Test nie powiódł się na etapie %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Przetestuj serwer @@ -7091,19 +7664,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Stara baza danych nie została usunięta podczas migracji, można ją usunąć. No comment provided by engineer. - - The profile is only shared with your contacts. - Profil jest udostępniany tylko Twoim kontaktom. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -7166,6 +7730,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Tego działania nie można cofnąć - wiadomości wysłane i odebrane wcześniej niż wybrane zostaną usunięte. Może to potrwać kilka minut. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone. @@ -7211,11 +7779,19 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom To jest twój jednorazowy link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**. @@ -7324,6 +7900,10 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Przełącz incognito przy połączeniu. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Nieprzezroczystość paska narzędzi @@ -7488,13 +8068,17 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Unmute Wyłącz wyciszenie - swipe action + notification label action Unread Nieprzeczytane swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Do nowych członków wysyłanych jest do 100 ostatnich wiadomości. @@ -7520,6 +8104,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Zaktualizować ustawienia? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Aktualizacja ustawień spowoduje ponowne połączenie klienta ze wszystkimi serwerami. @@ -7579,6 +8167,14 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Użyć serwerów SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Użyj czatu @@ -7641,6 +8237,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Używaj aplikacji podczas połączenia. @@ -7651,6 +8251,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Korzystaj z aplikacji jedną ręką. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Wybór użytkownika @@ -8019,10 +8623,6 @@ Powtórzyć prośbę dołączenia? Możesz to zmienić w ustawieniach wyglądu. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8111,6 +8711,10 @@ Powtórzyć prośbę dołączenia? Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Nie możesz wysyłać wiadomości! @@ -8193,6 +8797,10 @@ Powtórzyć prośbę połączenia? Wysłałeś zaproszenie do grupy No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później! @@ -8257,11 +8865,6 @@ Powtórzyć prośbę połączenia? Twoje serwery ICE No comment provided by engineer. - - Your SMP servers - Twoje serwery SMP - No comment provided by engineer. - Your SimpleX address Twój adres SimpleX @@ -8347,6 +8950,11 @@ Powtórzyć prośbę połączenia? Twój profil **%@** zostanie udostępniony. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Profil jest udostępniany tylko Twoim kontaktom. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Twój profil jest przechowywany na urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie mogą zobaczyć Twojego profilu. @@ -8357,11 +8965,6 @@ Powtórzyć prośbę połączenia? Twój profil został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do wszystkich kontaktów. alert message - - Your profile, contacts and delivered messages are stored on your device. - Twój profil, kontakty i dostarczone wiadomości są przechowywane na Twoim urządzeniu. - No comment provided by engineer. - Your random profile Twój losowy profil @@ -8374,6 +8977,7 @@ Powtórzyć prośbę połączenia? Your servers + Twoje serwery No comment provided by engineer. @@ -8411,6 +9015,10 @@ Powtórzyć prośbę połączenia? powyżej, a następnie wybierz: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call zaakceptowane połączenie @@ -8420,6 +9028,10 @@ Powtórzyć prośbę połączenia? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin administrator @@ -8440,6 +9052,10 @@ Powtórzyć prośbę połączenia? uzgadnianie szyfrowania… chat item text + + all + member criteria value + all members wszyscy członkowie @@ -8455,6 +9071,10 @@ Powtórzyć prośbę połączenia? i %lld innych wydarzeń No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts próby @@ -8493,7 +9113,8 @@ Powtórzyć prośbę połączenia? blocked by admin zablokowany przez admina - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8520,6 +9141,10 @@ Powtórzyć prośbę połączenia? dzwonie… call status + + can't send messages + No comment provided by engineer. + cancelled %@ anulowany %@ @@ -8625,6 +9250,14 @@ Powtórzyć prośbę połączenia? kontakt %1$@ zmieniony na %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kontakt posiada szyfrowanie e2e @@ -8635,6 +9268,10 @@ Powtórzyć prośbę połączenia? kontakt nie posiada szyfrowania e2e No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator twórca @@ -8663,7 +9300,8 @@ Powtórzyć prośbę połączenia? default (%@) domyślne (%@) - pref value + delete after time +pref value default (no) @@ -8790,20 +9428,11 @@ Powtórzyć prośbę połączenia? błąd No comment provided by engineer. - - event happened - nowe wydarzenie - No comment provided by engineer. - expired wygasły No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded przekazane dalej @@ -8814,6 +9443,10 @@ Powtórzyć prośbę połączenia? grupa usunięta No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated zaktualizowano profil grupy @@ -8939,6 +9572,10 @@ Powtórzyć prośbę połączenia? połączony rcv group event chat item + + member has old version + No comment provided by engineer. + message wiadomość @@ -8969,20 +9606,19 @@ Powtórzyć prośbę połączenia? moderowany przez %@ marked deleted chat item preview text + + moderator + member role + months miesiące time unit - - mute - wycisz - No comment provided by engineer. - never nigdy - No comment provided by engineer. + delete after time new message @@ -9004,6 +9640,10 @@ Powtórzyć prośbę połączenia? brak tekstu copied message info in history + + not synchronized + No comment provided by engineer. + observer obserwator @@ -9013,8 +9653,9 @@ Powtórzyć prośbę połączenia? off wyłączony enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9056,6 +9697,18 @@ Powtórzyć prośbę połączenia? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption kwantowo odporne szyfrowanie e2e @@ -9071,6 +9724,10 @@ Powtórzyć prośbę połączenia? otrzymano potwierdzenie… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call odrzucone połączenie @@ -9091,6 +9748,10 @@ Powtórzyć prośbę połączenia? usunięto adres kontaktu profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture usunięto zdjęcie profilu @@ -9101,10 +9762,22 @@ Powtórzyć prośbę połączenia? usunął cię rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved zapisane @@ -9204,11 +9877,6 @@ ostatnia otrzymana wiadomość: %2$@ nieznany status No comment provided by engineer. - - unmute - wyłącz wyciszenie - No comment provided by engineer. - unprotected niezabezpieczony @@ -9299,6 +9967,10 @@ ostatnia otrzymana wiadomość: %2$@ Ty No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group jesteś zaproszony do grupy @@ -9373,7 +10045,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -9410,7 +10082,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -9432,13 +10104,17 @@ ostatnia otrzymana wiadomość: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9451,15 +10127,11 @@ ostatnia otrzymana wiadomość: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -9481,7 +10153,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/contents.json b/apps/ios/SimpleX Localizations/pl.xcloc/contents.json index 0074d85662..c79fba1c1e 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/pl.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "pl", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff index 93ba6f357b..bbb6c7d22a 100644 --- a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff +++ b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff @@ -3002,8 +3002,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. O perfil é compartilhado apenas com seus contatos. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff index de1787bdad..bc8bf79da1 100644 --- a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff +++ b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff @@ -3146,8 +3146,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 5809c65216..2ec1130718 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (можно скопировать) @@ -144,7 +132,7 @@ %@, %@ and %lld other members connected - %@, %@ и %lld других членов соединены + установлено соединение с %@, %@ и %lld другими членами группы No comment provided by engineer. @@ -179,7 +167,7 @@ %d hours - %d ч. + %d час. time interval @@ -202,6 +190,11 @@ %d сек time interval + + %d seconds(s) + %d секунд + delete after time + %d skipped message(s) %d пропущенных сообщение(й) @@ -239,7 +232,7 @@ %lld members - Членов группы: %lld + %lld членов No comment provided by engineer. @@ -272,11 +265,6 @@ %lld новых языков интерфейса No comment provided by engineer. - - %lld second(s) - %lld секунд - No comment provided by engineer. - %lld seconds %lld секунд @@ -327,11 +315,6 @@ %u сообщений пропущено. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (новое) @@ -342,11 +325,6 @@ (это устройство v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Добавить контакт**: создать и поделиться новой ссылкой-приглашением. @@ -412,11 +390,6 @@ \*жирный* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -453,11 +426,6 @@ - история редактирования. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 сек @@ -471,7 +439,8 @@ 1 day 1 день - time interval + delete after time +time interval 1 hour @@ -486,12 +455,19 @@ 1 month 1 месяц - time interval + delete after time +time interval 1 week 1 неделю - time interval + delete after time +time interval + + + 1 year + 1 год + delete after time 1-time link @@ -518,11 +494,6 @@ 30 секунд No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -553,8 +524,8 @@ A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. - Отдельное TCP-соединение (и авторизация SOCKS) будет использоваться **для каждого контакта и члена группы**. -**Обратите внимание**: если у Вас много контактов, потребление батареи и трафика может быть значительно выше, и некоторые соединения могут не работать. + Будет использовано отдельное TCP соединение **для каждого контакта и члена группы**. +**Примечание**: Чем больше подключений, тем быстрее разряжается батарея и расходуется трафик, а некоторые соединения могут отваливаться. No comment provided by engineer. @@ -591,8 +562,18 @@ Accept Принять accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + Принять в группу + alert action + + + Accept as observer + Принять как читателя + alert action Accept conditions @@ -613,7 +594,12 @@ Accept incognito Принять инкогнито accept contact request via notification - swipe action +swipe action + + + Accept member + Принять члена + alert title Accepted conditions @@ -630,6 +616,11 @@ Ошибки подтверждения No comment provided by engineer. + + Active + Активный + token status text + Active connections Активные соединения @@ -645,6 +636,11 @@ Добавить друзей No comment provided by engineer. + + Add list + Добавить список + No comment provided by engineer. + Add profile Добавить профиль @@ -670,6 +666,11 @@ Добавить на другое устройство No comment provided by engineer. + + Add to list + Добавить в список + No comment provided by engineer. + Add welcome message Добавить приветственное сообщение @@ -745,6 +746,11 @@ Настройки сети No comment provided by engineer. + + All + Все + No comment provided by engineer. + All app data is deleted. Все данные приложения будут удалены. @@ -755,6 +761,11 @@ Все чаты и сообщения будут удалены - это нельзя отменить! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + Все чаты будут удалены из списка %@, и список удален. + alert message + All data is erased when it is entered. Все данные удаляются при его вводе. @@ -767,7 +778,7 @@ All group members will remain connected. - Все члены группы, которые соединились через эту ссылку, останутся в группе. + Все члены группы останутся соединены. No comment provided by engineer. @@ -795,6 +806,16 @@ Все профили profile dropdown + + All reports will be archived for you. + Все сообщения о нарушениях будут заархивированы для вас. + No comment provided by engineer. + + + All servers + Все серверы + No comment provided by engineer. + All your contacts will remain connected. Все контакты, которые соединились через этот адрес, сохранятся. @@ -852,7 +873,7 @@ Allow sending direct messages to members. - Разрешить посылать прямые сообщения членам группы. + Разрешить личные сообщения членам группы. No comment provided by engineer. @@ -870,6 +891,11 @@ Разрешить необратимо удалять отправленные сообщения. (24 часа) No comment provided by engineer. + + Allow to report messsages to moderators. + Разрешить отправлять сообщения о нарушениях модераторам. + No comment provided by engineer. + Allow to send SimpleX links. Разрешить отправлять ссылки SimpleX. @@ -950,6 +976,11 @@ Будет создан пустой профиль чата с указанным именем, и приложение откроется в обычном режиме. No comment provided by engineer. + + Another reason + Другая причина + report reason + Answer call Принять звонок @@ -975,6 +1006,11 @@ Приложение шифрует новые локальные файлы (кроме видео). No comment provided by engineer. + + App group: + Группа приложения: + No comment provided by engineer. + App icon Иконка @@ -1020,6 +1056,21 @@ Применить к No comment provided by engineer. + + Archive + Архивировать + No comment provided by engineer. + + + Archive %lld reports? + Архивировать %lld сообщений о нарушениях? + No comment provided by engineer. + + + Archive all reports? + Архивировать все сообщения о нарушениях? + No comment provided by engineer. + Archive and upload Архивировать и загрузить @@ -1030,6 +1081,21 @@ Архивируйте контакты чтобы продолжить переписку. No comment provided by engineer. + + Archive report + Архивировать сообщение о нарушении + No comment provided by engineer. + + + Archive report? + Архивировать сообщение о нарушении? + No comment provided by engineer. + + + Archive reports + Архивировать сообщения о нарушениях + swipe action + Archived contacts Архивированные контакты @@ -1140,6 +1206,11 @@ Улучшенные группы No comment provided by engineer. + + Better groups performance + Улучшенная производительность групп + No comment provided by engineer. + Better message dates. Улучшенные даты сообщений. @@ -1160,6 +1231,11 @@ Улучшенные уведомления No comment provided by engineer. + + Better privacy and security + Улучшенная конфиденциальность и безопасность + No comment provided by engineer. + Better security ✅ Улучшенная безопасность ✅ @@ -1187,7 +1263,7 @@ Block group members - Блокируйте членов группы + Заблокировать членов группы No comment provided by engineer. @@ -1197,7 +1273,7 @@ Block member for all? - Заблокировать члена для всех? + Заблокировать для всех? No comment provided by engineer. @@ -1260,11 +1336,25 @@ Бизнес разговоры No comment provided by engineer. + + Businesses + Бизнесы + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Используя SimpleX Chat, Вы согласны: +- отправлять только законные сообщения в публичных группах. +- уважать других пользователей – не отправлять спам. + No comment provided by engineer. + Call already ended! Звонок уже завершен! @@ -1292,7 +1382,7 @@ Can't call member - Не удается позвонить члену группы + Не удаётся позвонить члену группы No comment provided by engineer. @@ -1307,14 +1397,14 @@ Can't message member - Не удается написать члену группы + Не удаётся отправить сообщение члену группы No comment provided by engineer. Cancel Отменить alert action - alert button +alert button Cancel migration @@ -1351,6 +1441,11 @@ Поменять No comment provided by engineer. + + Change automatic message deletion? + Измененить автоматическое удаление сообщений? + alert title + Change chat profiles Поменять профили @@ -1400,7 +1495,7 @@ Change self-destruct passcode Изменить код самоуничтожения authentication reason - set passcode view +set passcode view Chat @@ -1502,11 +1597,26 @@ Разговор будет удален для Вас - это действие нельзя отменить! No comment provided by engineer. + + Chat with admins + Чат с админами + chat toolbar + + + Chat with member + Чат с членом группы + No comment provided by engineer. + Chats Чаты No comment provided by engineer. + + Chats with members + Чаты с членами группы + No comment provided by engineer. + Check messages every 20 min. Проверять сообщения каждые 20 минут. @@ -1572,6 +1682,16 @@ Очистить разговор? No comment provided by engineer. + + Clear group? + Очистить группу? + No comment provided by engineer. + + + Clear or delete group? + Очистить или удалить группу? + No comment provided by engineer. + Clear private notes? Очистить личные заметки? @@ -1592,6 +1712,11 @@ Режим цветов No comment provided by engineer. + + Community guidelines violation + Нарушение правил группы + report reason + Compare file Сравнение файла @@ -1627,16 +1752,6 @@ Условия использования No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Условия будут приняты для включенных операторов через 30 дней. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Условия будут приняты для оператора(ов): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Условия будут приняты для оператора(ов): **%@**. @@ -1657,6 +1772,11 @@ Настройка ICE серверов No comment provided by engineer. + + Configure server operators + Настроить операторов серверов + No comment provided by engineer. + Confirm Подтвердить @@ -1707,6 +1827,11 @@ Подтвердить загрузку No comment provided by engineer. + + Confirmed + Подтвержденный + token status text + Connect Соединиться @@ -1826,6 +1951,11 @@ This is your own one-time link! Состояние соединения и серверов. No comment provided by engineer. + + Connection blocked + Соединение заблокировано + No comment provided by engineer. + Connection error Ошибка соединения @@ -1836,6 +1966,18 @@ This is your own one-time link! Ошибка соединения (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + Соединение заблокировано сервером оператора: +%@ + No comment provided by engineer. + + + Connection not ready. + Соединение не готово. + No comment provided by engineer. + Connection notifications Уведомления по соединениям @@ -1846,6 +1988,11 @@ This is your own one-time link! Запрос на соединение отправлен! No comment provided by engineer. + + Connection requires encryption renegotiation. + Соединение требует повторного согласования шифрования. + No comment provided by engineer. + Connection security Безопасность соединения @@ -1926,6 +2073,11 @@ This is your own one-time link! Контакты могут помечать сообщения для удаления; Вы сможете просмотреть их. No comment provided by engineer. + + Content violates conditions of use + Содержание нарушает условия использования + blocking reason + Continue Продолжить @@ -2001,6 +2153,11 @@ This is your own one-time link! Создать ссылку No comment provided by engineer. + + Create list + Создать список + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Создайте новый профиль в [приложении для компьютера](https://simplex.chat/downloads/). 💻 @@ -2218,8 +2375,7 @@ This is your own one-time link! Delete Удалить alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2261,6 +2417,11 @@ This is your own one-time link! Удалить разговор No comment provided by engineer. + + Delete chat messages from your device. + Удалить сообщения с вашего устройства. + No comment provided by engineer. + Delete chat profile Удалить профиль чата @@ -2271,6 +2432,11 @@ This is your own one-time link! Удалить профиль? No comment provided by engineer. + + Delete chat with member? + Удалить чат с членом группы? + alert title + Delete chat? Удалить разговор? @@ -2351,6 +2517,11 @@ This is your own one-time link! Удалить ссылку? No comment provided by engineer. + + Delete list? + Удалить список? + alert title + Delete member message? Удалить сообщение участника? @@ -2364,7 +2535,7 @@ This is your own one-time link! Delete messages Удалить сообщения - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2572,11 @@ This is your own one-time link! Удаление очереди server test step + + Delete report + Удалить сообщение о нарушении + No comment provided by engineer. + Delete up to 20 messages at once. Удаляйте до 20 сообщений за раз. @@ -2543,12 +2719,12 @@ This is your own one-time link! Direct messages between members are prohibited in this chat. - Прямые сообщения между членами запрещены в этом разговоре. + Личные сообщения запрещены в этой группе. No comment provided by engineer. Direct messages between members are prohibited. - Прямые сообщения между членами группы запрещены. + Прямые сообщения между членами запрещены. No comment provided by engineer. @@ -2561,6 +2737,16 @@ This is your own one-time link! Отключить блокировку SimpleX authentication reason + + Disable automatic message deletion? + Отключить автоматическое удаление сообщений? + alert title + + + Disable delete messages + Отключить удаление сообщений + alert button + Disable for all Выключить для всех @@ -2651,6 +2837,11 @@ This is your own one-time link! Не использовать учетные данные с прокси. No comment provided by engineer. + + Documents: + Документы: + No comment provided by engineer. + Don't create address Не создавать адрес @@ -2661,9 +2852,19 @@ This is your own one-time link! Не включать No comment provided by engineer. + + Don't miss important messages. + Не пропустите важные сообщения. + No comment provided by engineer. + Don't show again Не показывать + alert action + + + Done + Готово No comment provided by engineer. @@ -2675,7 +2876,7 @@ This is your own one-time link! Download Загрузить alert button - chat item action +chat item action Download errors @@ -2752,9 +2953,9 @@ This is your own one-time link! Включить (кроме исключений) No comment provided by engineer. - - Enable Flux - Включить Flux + + Enable Flux in Network & servers settings for better metadata privacy. + Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных. No comment provided by engineer. @@ -2770,7 +2971,7 @@ This is your own one-time link! Enable automatic message deletion? Включить автоматическое удаление сообщений? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3098,11 @@ This is your own one-time link! Ошибка нового соглашения о шифровании. No comment provided by engineer. + + Encryption renegotiation in progress. + Выполняется повторное согласование шифрования. + No comment provided by engineer. + Enter Passcode Введите Код @@ -2972,6 +3178,11 @@ This is your own one-time link! Ошибка при принятии запроса на соединение No comment provided by engineer. + + Error accepting member + Ошибка вступления члена группы + alert title + Error adding member(s) Ошибка при добавлении членов группы @@ -3007,6 +3218,11 @@ This is your own one-time link! Ошибка при смене на Инкогнито! No comment provided by engineer. + + Error checking token status + Ошибка проверки статуса токена + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Ошибка подключения к пересылающему серверу %@. Попробуйте позже. @@ -3027,9 +3243,14 @@ This is your own one-time link! Ошибка при создании ссылки группы No comment provided by engineer. + + Error creating list + Ошибка создания списка + alert title + Error creating member contact - Ошибка создания контакта с членом группы + Ошибка при создании контакта No comment provided by engineer. @@ -3042,6 +3263,11 @@ This is your own one-time link! Ошибка создания профиля! No comment provided by engineer. + + Error creating report + Ошибка создания сообщения о нарушении + No comment provided by engineer. + Error decrypting file Ошибка расшифровки файла @@ -3052,6 +3278,11 @@ This is your own one-time link! Ошибка при удалении данных чата No comment provided by engineer. + + Error deleting chat with member + Ошибка при удалении чата с членом группы + alert title + Error deleting chat! Ошибка при удалении чата! @@ -3152,10 +3383,20 @@ This is your own one-time link! Ошибка переподключения к серверам No comment provided by engineer. + + Error registering for notifications + Ошибка регистрации для уведомлений + alert title + Error removing member Ошибка при удалении члена группы - No comment provided by engineer. + alert title + + + Error reordering lists + Ошибка сортировки списков + alert title Error resetting statistics @@ -3167,6 +3408,11 @@ This is your own one-time link! Ошибка при сохранении ICE серверов No comment provided by engineer. + + Error saving chat list + Ошибка сохранения списка чатов + alert title + Error saving group profile Ошибка при сохранении профиля группы @@ -3209,7 +3455,7 @@ This is your own one-time link! Error sending member contact invitation - Ошибка отправки приглашения члену группы + Ошибка при отправке приглашения члену No comment provided by engineer. @@ -3247,6 +3493,11 @@ This is your own one-time link! Ошибка синхронизации соединения No comment provided by engineer. + + Error testing server connection + Ошибка проверки соединения с сервером + No comment provided by engineer. + Error updating group link Ошибка обновления ссылки группы @@ -3290,7 +3541,9 @@ This is your own one-time link! Error: %@ Ошибка: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3327,6 +3580,11 @@ This is your own one-time link! Раскрыть chat item action + + Expired + Истекший + token status text + Export database Экспорт архива чата @@ -3367,20 +3625,35 @@ This is your own one-time link! Быстрые и не нужно ждать, когда отправитель онлайн! No comment provided by engineer. + + Faster deletion of groups. + Ускорено удаление групп. + No comment provided by engineer. + Faster joining and more reliable messages. Быстрое вступление и надежная доставка сообщений. No comment provided by engineer. + + Faster sending messages. + Ускорена отправка сообщений. + No comment provided by engineer. + Favorite Избранный swipe action + + Favorites + Избранное + No comment provided by engineer. + File error Ошибка файла - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3662,13 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +%@. + Файл заблокирован оператором сервера: +%@. + file error text + File not found - most likely file was deleted or cancelled. Файл не найден - скорее всего, файл был удален или отменен. @@ -3511,7 +3791,12 @@ This is your own one-time link! Fix not supported by group member - Починка не поддерживается членом группы + Починка не поддерживается членом группы. + No comment provided by engineer. + + + For all moderators + Для всех модераторов No comment provided by engineer. @@ -3529,6 +3814,11 @@ This is your own one-time link! Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux. No comment provided by engineer. + + For me + Для меня + No comment provided by engineer. + For private routing Для доставки сообщений @@ -3653,6 +3943,11 @@ Error: %2$@ ГИФ файлы и стикеры No comment provided by engineer. + + Get notified when mentioned. + Уведомления, когда Вас упомянули. + No comment provided by engineer. + Good afternoon! Добрый день! @@ -3758,11 +4053,21 @@ Error: %2$@ Группа будет удалена для Вас - это действие нельзя отменить! No comment provided by engineer. + + Groups + Группы + No comment provided by engineer. + Help Помощь No comment provided by engineer. + + Help admins moderating their groups. + Помогайте администраторам модерировать их группы. + No comment provided by engineer. + Hidden Скрытое @@ -3823,6 +4128,11 @@ Error: %2$@ Как это улучшает конфиденциальность No comment provided by engineer. + + How it works + Как это работает + alert button + How to Инфо @@ -3964,6 +4274,16 @@ More improvements are coming soon! Звуки во время звонков No comment provided by engineer. + + Inappropriate content + Неприемлемый контент + report reason + + + Inappropriate profile + Неприемлемый профиль + report reason + Incognito Инкогнито @@ -4056,6 +4376,31 @@ More improvements are coming soon! Цвета интерфейса No comment provided by engineer. + + Invalid + Недействительный + token status text + + + Invalid (bad token) + Недействительный (плохой токен) + token status text + + + Invalid (expired) + Недействительный (истекший) + token status text + + + Invalid (unregistered) + Недействительный (незарегистрированный) + token status text + + + Invalid (wrong topic) + Недействительный (плохой заголовок) + token status text + Invalid QR code Неверный QR код @@ -4329,6 +4674,21 @@ This is your link for group %@! Связанные компьютеры No comment provided by engineer. + + List + Список + swipe action + + + List name and emoji should be different for all lists. + Название списка и эмодзи должны быть разными для всех списков. + No comment provided by engineer. + + + List name... + Имя списка... + No comment provided by engineer. + Live message! Живое сообщение! @@ -4414,11 +4774,21 @@ This is your link for group %@! Член группы No comment provided by engineer. + + Member admission + Приём членов в группу + No comment provided by engineer. + Member inactive Член неактивен item status text + + Member reports + Сообщения о нарушениях + chat feature + Member role will be changed to "%@". All chat members will be notified. Роль участника будет изменена на "%@". Все участники разговора получат уведомление. @@ -4426,12 +4796,12 @@ This is your link for group %@! Member role will be changed to "%@". All group members will be notified. - Роль члена группы будет изменена на "%@". Все члены группы получат сообщение. + Роль члена будет изменена на "%@". Все члены группы получат уведомление. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. - Роль члена группы будет изменена на "%@". Будет отправлено новое приглашение. + Роль члена будет изменена на "%@". Будет отправлено новое приглашение. No comment provided by engineer. @@ -4444,39 +4814,54 @@ This is your link for group %@! Член группы будет удален - это действие нельзя отменить! No comment provided by engineer. + + Member will join the group, accept member? + Участник хочет присоединиться к группе. Принять? + alert message + Members can add message reactions. - Члены группы могут добавлять реакции на сообщения. + Члены могут добавлять реакции на сообщения. No comment provided by engineer. Members can irreversibly delete sent messages. (24 hours) - Члены группы могут необратимо удалять отправленные сообщения. (24 часа) + Члены могут необратимо удалять отправленные сообщения. (24 часа) + No comment provided by engineer. + + + Members can report messsages to moderators. + Члены группы могут пожаловаться модераторам. No comment provided by engineer. Members can send SimpleX links. - Члены группы могут отправлять ссылки SimpleX. + Члены могут отправлять ссылки SimpleX. No comment provided by engineer. Members can send direct messages. - Члены группы могут посылать прямые сообщения. + Члены могут посылать прямые сообщения. No comment provided by engineer. Members can send disappearing messages. - Члены группы могут посылать исчезающие сообщения. + Члены могут посылать исчезающие сообщения. No comment provided by engineer. Members can send files and media. - Члены группы могут слать файлы и медиа. + Члены могут слать файлы и медиа. No comment provided by engineer. Members can send voice messages. - Члены группы могут отправлять голосовые сообщения. + Члены могут отправлять голосовые сообщения. + No comment provided by engineer. + + + Mention members 👋 + Упоминайте участников 👋 No comment provided by engineer. @@ -4589,6 +4974,11 @@ This is your link for group %@! Сообщения от %@ будут показаны! No comment provided by engineer. + + Messages in this chat will never be deleted. + Сообщения в этом чате никогда не будут удалены. + alert message + Messages received Получено сообщений @@ -4689,6 +5079,11 @@ This is your link for group %@! Модерировано: %@ copied message info + + More + Больше + swipe action + More improvements are coming soon! Дополнительные улучшения скоро! @@ -4717,7 +5112,12 @@ This is your link for group %@! Mute Без звука - swipe action + notification label action + + + Mute all + Все без звука + notification label action Muted when inactive! @@ -4769,6 +5169,11 @@ This is your link for group %@! Состояние сети No comment provided by engineer. + + New + Новый + token status text + New Passcode Новый Код @@ -4834,6 +5239,11 @@ This is your link for group %@! Роль члена группы No comment provided by engineer. + + New member wants to join the group. + Новый участник хочет присоединиться к группе. + rcv group event chat item + New message Новое сообщение @@ -4859,6 +5269,26 @@ This is your link for group %@! Нет кода доступа Authentication unavailable + + No chats + Нет чатов + No comment provided by engineer. + + + No chats found + Чаты не найдены + No comment provided by engineer. + + + No chats in list %@ + Нет чатов в списке %@ + No comment provided by engineer. + + + No chats with members + Нет чатов с членами группы + No comment provided by engineer. + No contacts selected Контакты не выбраны @@ -4909,6 +5339,11 @@ This is your link for group %@! Нет серверов файлов и медиа. servers error + + No message + Нет сообщения + No comment provided by engineer. + No message servers. Нет серверов сообщений. @@ -4964,6 +5399,16 @@ This is your link for group %@! Нет серверов для отправки файлов. servers error + + No token! + Нет токена! + alert title + + + No unread chats + Нет непрочитанных чатов + No comment provided by engineer. + No user identifiers. Без идентификаторов пользователей. @@ -4974,6 +5419,11 @@ This is your link for group %@! Несовместимая версия! No comment provided by engineer. + + Notes + Заметки + No comment provided by engineer. + Nothing selected Ничего не выбрано @@ -4994,18 +5444,28 @@ This is your link for group %@! Уведомления выключены No comment provided by engineer. + + Notifications error + Ошибка уведомлений + alert title + Notifications privacy Конфиденциальность уведомлений No comment provided by engineer. + + Notifications status + Статус уведомлений + alert title + Now admins can: - delete members' messages. - disable members ("observer" role) Теперь админы могут: - удалять сообщения членов. -- приостанавливать членов (роль "наблюдатель") +- приостанавливать членов (роль наблюдатель) No comment provided by engineer. @@ -5021,7 +5481,8 @@ This is your link for group %@! Ok Ок - alert button + alert action +alert button Old database @@ -5082,6 +5543,16 @@ Requires compatible VPN. Только владельцы группы могут разрешить голосовые сообщения. No comment provided by engineer. + + Only sender and moderators see it + Только отправитель и модераторы видят это + No comment provided by engineer. + + + Only you and moderators see it + Только вы и модераторы видят это + No comment provided by engineer. + Only you can add message reactions. Только Вы можете добавлять реакции на сообщения. @@ -5135,7 +5606,7 @@ Requires compatible VPN. Open Открыть - No comment provided by engineer. + alert action Open Settings @@ -5167,6 +5638,11 @@ Requires compatible VPN. Открыть группу No comment provided by engineer. + + Open link? + Открыть ссылку? + alert title + Open migration to another device Открытие миграции на другое устройство @@ -5217,6 +5693,11 @@ Requires compatible VPN. Или поделиться конфиденциально No comment provided by engineer. + + Organize chats into lists + Организуйте чаты в списки + No comment provided by engineer. + Other Другaя сеть @@ -5301,7 +5782,7 @@ Requires compatible VPN. Pending - В ожидании + Ожидает No comment provided by engineer. @@ -5408,6 +5889,26 @@ Error: %@ Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Попробуйте выключить и снова включить уведомления. + token info + + + Please wait for group moderators to review your request to join the group. + Пожалуйста, подождите, пока модераторы группы рассмотрят ваш запрос на вступление. + snd group event chat item + + + Please wait for token activation to complete. + Пожалуйста, дождитесь завершения активации токена. + token info + + + Please wait for token to be registered. + Пожалуйста, дождитесь регистрации токена. + token info + Polish interface Польский интерфейс @@ -5458,16 +5959,31 @@ Error: %@ Конфиденциальность для ваших покупателей. No comment provided by engineer. + + Privacy policy and conditions of use. + Политика конфиденциальности и условия использования. + No comment provided by engineer. + Privacy redefined Более конфиденциальный No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. + No comment provided by engineer. + Private filenames Защищенные имена файлов No comment provided by engineer. + + Private media file names. + Конфиденциальные названия медиафайлов. + No comment provided by engineer. + Private message routing Конфиденциальная доставка сообщений @@ -5543,6 +6059,11 @@ Error: %@ Запретить реакции на сообщения. No comment provided by engineer. + + Prohibit reporting messages to moderators. + Запретить жаловаться модераторам группы. + No comment provided by engineer. + Prohibit sending SimpleX links. Запретить отправку ссылок SimpleX. @@ -5800,11 +6321,26 @@ Enable in *Network & servers* settings. Уменьшенное потребление батареи No comment provided by engineer. + + Register + Зарегистрировать + No comment provided by engineer. + + + Register notification token? + Зарегистрировать токен уведомлений? + token info + + + Registered + Зарегистрирован + token status text + Reject Отклонить reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5816,6 +6352,11 @@ Enable in *Network & servers* settings. Отклонить запрос No comment provided by engineer. + + Reject member? + Отклонить участника? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relay сервер используется только при необходимости. Другая сторона может видеть Ваш IP адрес. @@ -5901,6 +6442,61 @@ Enable in *Network & servers* settings. Ответить chat item action + + Report + Пожаловаться + chat item action + + + Report content: only group moderators will see it. + Пожаловаться на сообщение: увидят только модераторы группы. + report reason + + + Report member profile: only group moderators will see it. + Пожаловаться на профиль: увидят только модераторы группы. + report reason + + + Report other: only group moderators will see it. + Пожаловаться: увидят только модераторы группы. + report reason + + + Report reason? + Причина сообщения? + No comment provided by engineer. + + + Report sent to moderators + Жалоба отправлена модераторам + alert title + + + Report spam: only group moderators will see it. + Пожаловаться на спам: увидят только модераторы группы. + report reason + + + Report violation: only group moderators will see it. + Пожаловаться на нарушение: увидят только модераторы группы. + report reason + + + Report: %@ + Сообщение о нарушении: %@ + report in notification + + + Reporting messages to moderators is prohibited. + Сообщения о нарушениях запрещены в этой группе. + No comment provided by engineer. + + + Reports + Сообщения о нарушениях + No comment provided by engineer. + Required Обязательно @@ -5991,10 +6587,15 @@ Enable in *Network & servers* settings. Посмотреть условия No comment provided by engineer. - - Review later - Посмотреть позже - No comment provided by engineer. + + Review members + Одобрять членов + admission stage + + + Review members before admitting ("knocking"). + Одобрять членов для вступления в группу. + admission stage description Revoke @@ -6045,13 +6646,18 @@ Enable in *Network & servers* settings. Save Сохранить alert button - chat item action +chat item action Save (and notify contacts) Сохранить (и уведомить контакты) alert button + + Save admission settings? + Сохранить настройки вступления? + alert title + Save and notify contact Сохранить и уведомить контакт @@ -6077,6 +6683,11 @@ Enable in *Network & servers* settings. Сохранить профиль группы No comment provided by engineer. + + Save list + Сохранить список + No comment provided by engineer. + Save passphrase and open chat Сохранить пароль и открыть чат @@ -6317,6 +6928,11 @@ Enable in *Network & servers* settings. Отправлять уведомления No comment provided by engineer. + + Send private reports + Вы можете сообщить о нарушениях + No comment provided by engineer. + Send questions and ideas Отправьте вопросы и идеи @@ -6532,6 +7148,11 @@ Enable in *Network & servers* settings. Установить 1 день No comment provided by engineer. + + Set chat name… + Имя чата… + No comment provided by engineer. + Set contact name… Имя контакта… @@ -6552,6 +7173,16 @@ Enable in *Network & servers* settings. Установите код вместо системной аутентификации. No comment provided by engineer. + + Set member admission + Приём членов в группу + No comment provided by engineer. + + + Set message expiration in chats. + Установите срок хранения сообщений в чатах. + No comment provided by engineer. + Set passcode Установить код доступа @@ -6596,7 +7227,7 @@ Enable in *Network & servers* settings. Share Поделиться alert action - chat item action +chat item action Share 1-time link @@ -6658,6 +7289,11 @@ Enable in *Network & servers* settings. Поделиться с контактами No comment provided by engineer. + + Short link + Короткая ссылка + No comment provided by engineer. + Show QR code Показать QR код @@ -6758,6 +7394,11 @@ Enable in *Network & servers* settings. Адрес SimpleX или одноразовая ссылка? No comment provided by engineer. + + SimpleX channel link + SimpleX ссылка канала + simplex link type + SimpleX contact address SimpleX ссылка-контакт @@ -6860,6 +7501,12 @@ Enable in *Network & servers* settings. Контакт notification title + + Spam + Спам + blocking reason +report reason + Square, circle, or anything in between. Квадрат, круг и все, что между ними. @@ -6945,6 +7592,11 @@ Enable in *Network & servers* settings. Остановка чата No comment provided by engineer. + + Storage + Хранилище + No comment provided by engineer. + Strong Сильное @@ -7005,6 +7657,11 @@ Enable in *Network & servers* settings. Таймаут TCP соединения No comment provided by engineer. + + TCP port for messaging + TCP-порт для отправки сообщений + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7073,13 +7730,18 @@ Enable in *Network & servers* settings. Temporary file error Временная ошибка файла - No comment provided by engineer. + file error alert title Test failed at step %@. Ошибка теста на шаге %@. server test failure + + Test notifications + Протестировать уведомления + No comment provided by engineer. + Test server Тестировать сервер @@ -7202,21 +7864,11 @@ It can happen because of some bug or when the connection is compromised.Предыдущая версия данных чата не удалена при перемещении, её можно удалить. No comment provided by engineer. - - The profile is only shared with your contacts. - Профиль отправляется только Вашим контактам. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Те же самые условия будут приняты для оператора **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Те же самые условия будут приняты для оператора(ов): **%@**. - No comment provided by engineer. - The second preset operator in the app! Второй оператор серверов в приложении! @@ -7282,6 +7934,11 @@ It can happen because of some bug or when the connection is compromised.Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Это действие нельзя отменить - сообщения в этом чате, отправленные или полученные раньше чем выбрано, будут удалены. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. @@ -7309,7 +7966,7 @@ It can happen because of some bug or when the connection is compromised. This group has over %lld members, delivery receipts are not sent. - В группе более %lld членов, отчёты о доставке выключены. + В этой группе более %lld членов, отчёты о доставке не отправляются. No comment provided by engineer. @@ -7327,11 +7984,21 @@ It can happen because of some bug or when the connection is compromised.Это ваша собственная одноразовая ссылка! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Эта ссылка была использована на другом мобильном, пожалуйста, создайте новую ссылку на компьютере. No comment provided by engineer. + + This message was deleted or not received yet. + Это сообщение было удалено или еще не получено. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**. @@ -7444,6 +8111,11 @@ You will be prompted to complete authentication before this feature is enabled.< Установите режим Инкогнито при соединении. No comment provided by engineer. + + Token status: %@. + Статус токена: %@. + token status + Toolbar opacity Прозрачность тулбара @@ -7609,13 +8281,18 @@ To connect, please ask your contact to create another connection link and check Unmute Уведомлять - swipe action + notification label action Unread Не прочитано swipe action + + Unsupported connection link + Ссылка не поддерживается + No comment provided by engineer. + Up to 100 last messages are sent to new members. До 100 последних сообщений отправляются новым членам. @@ -7641,6 +8318,11 @@ To connect, please ask your contact to create another connection link and check Обновить настройки? No comment provided by engineer. + + Updated conditions + Обновленные условия + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами. @@ -7701,6 +8383,16 @@ To connect, please ask your contact to create another connection link and check Использовать серверы предосталенные SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + Использовать TCP-порт %@, когда порт не указан. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + Использовать TCP-порт 443 только для серверов по умолчанию. + No comment provided by engineer. + Use chat Использовать чат @@ -7766,6 +8458,11 @@ To connect, please ask your contact to create another connection link and check Использовать серверы No comment provided by engineer. + + Use short links (BETA) + Короткие ссылки (БЕТА) + No comment provided by engineer. + Use the app while in the call. Используйте приложение во время звонка. @@ -7776,6 +8473,11 @@ To connect, please ask your contact to create another connection link and check Используйте приложение одной рукой. No comment provided by engineer. + + Use web port + Использовать веб-порт + No comment provided by engineer. + User selection Выбор пользователя @@ -8148,11 +8850,6 @@ Repeat join request? Вы можете изменить это в настройках Интерфейса. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Вы можете настроить операторов в настройках Сети и серверов. - No comment provided by engineer. - You can configure servers via settings. Вы можете настроить серверы позже. @@ -8243,6 +8940,11 @@ Repeat join request? Вы можете увидеть ссылку-приглашение снова открыв соединение. alert message + + You can view your reports in Chat with admins. + Вы можете найти Ваши жалобы в Чате с админами. + alert message + You can't send messages! Вы не можете отправлять сообщения! @@ -8325,6 +9027,11 @@ Repeat connection request? Вы отправили приглашение в группу No comment provided by engineer. + + You should receive notifications. + Вы должны получать уведомления. + token info + You will be connected to group when the group host's device is online, please wait or check later! Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже! @@ -8390,11 +9097,6 @@ Repeat connection request? Ваши ICE серверы No comment provided by engineer. - - Your SMP servers - Ваши SMP серверы - No comment provided by engineer. - Your SimpleX address Ваш адрес SimpleX @@ -8480,6 +9182,11 @@ Repeat connection request? Будет отправлен Ваш профиль **%@**. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Ваш профиль храниться на Вашем устройстве и отправляется только контактам. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю. @@ -8490,11 +9197,6 @@ Repeat connection request? Ваш профиль был изменен. Если вы сохраните его, обновленный профиль будет отправлен всем вашим контактам. alert message - - Your profile, contacts and delivered messages are stored on your device. - Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве. - No comment provided by engineer. - Your random profile Случайный профиль @@ -8545,6 +9247,11 @@ Repeat connection request? наверху, затем выберите: No comment provided by engineer. + + accepted %@ + принят %@ + rcv group event chat item + accepted call принятый звонок @@ -8555,6 +9262,11 @@ Repeat connection request? принятое приглашение chat list item title + + accepted you + Вы приняты + rcv group event chat item + admin админ @@ -8575,6 +9287,11 @@ Repeat connection request? шифрование согласовывается… chat item text + + all + все + member criteria value + all members все члены @@ -8590,6 +9307,11 @@ Repeat connection request? и %lld других событий No comment provided by engineer. + + archived report + заархивированное сообщение о нарушении + No comment provided by engineer. + attempts попытки @@ -8628,7 +9350,8 @@ Repeat connection request? blocked by admin заблокировано администратором - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8655,6 +9378,11 @@ Repeat connection request? входящий звонок… call status + + can't send messages + нельзя отправлять + No comment provided by engineer. + cancelled %@ отменил(a) %@ @@ -8760,6 +9488,16 @@ Repeat connection request? контакт %1$@ изменён на %2$@ profile update event chat item + + contact deleted + контакт удален + No comment provided by engineer. + + + contact disabled + контакт выключен + No comment provided by engineer. + contact has e2e encryption у контакта есть e2e шифрование @@ -8770,6 +9508,11 @@ Repeat connection request? у контакта нет e2e шифрования No comment provided by engineer. + + contact not ready + контакт не готов + No comment provided by engineer. + creator создатель @@ -8798,7 +9541,8 @@ Repeat connection request? default (%@) по умолчанию (%@) - pref value + delete after time +pref value default (no) @@ -8925,21 +9669,11 @@ Repeat connection request? ошибка No comment provided by engineer. - - event happened - событие произошло - No comment provided by engineer. - expired истекло No comment provided by engineer. - - for better metadata privacy. - для лучшей конфиденциальности метаданных. - No comment provided by engineer. - forwarded переслано @@ -8950,6 +9684,11 @@ Repeat connection request? группа удалена No comment provided by engineer. + + group is deleted + группа удалена + No comment provided by engineer. + group profile updated профиль группы обновлен @@ -9075,6 +9814,11 @@ Repeat connection request? соединен(а) rcv group event chat item + + member has old version + член имеет старую версию + No comment provided by engineer. + message написать @@ -9105,20 +9849,20 @@ Repeat connection request? удалено %@ marked deleted chat item preview text + + moderator + модератор + member role + months месяцев time unit - - mute - без звука - No comment provided by engineer. - never никогда - No comment provided by engineer. + delete after time new message @@ -9140,6 +9884,11 @@ Repeat connection request? нет текста copied message info in history + + not synchronized + не синхронизирован + No comment provided by engineer. + observer читатель @@ -9149,8 +9898,9 @@ Repeat connection request? off нет enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9192,6 +9942,21 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + ожидает + No comment provided by engineer. + + + pending approval + ожидает утверждения + No comment provided by engineer. + + + pending review + ожидает одобрения + No comment provided by engineer. + quantum resistant e2e encryption квантово-устойчивое e2e шифрование @@ -9207,6 +9972,11 @@ Repeat connection request? получено подтверждение… No comment provided by engineer. + + rejected + отклонён + No comment provided by engineer. + rejected call отклонённый звонок @@ -9227,6 +9997,11 @@ Repeat connection request? удалён адрес контакта profile update event chat item + + removed from group + удален из группы + No comment provided by engineer. + removed profile picture удалена картинка профиля @@ -9237,11 +10012,26 @@ Repeat connection request? удалил(а) Вас из группы rcv group event chat item + + request to join rejected + запрос на вступление отклонён + No comment provided by engineer. + requested to connect запрошено соединение chat list item title + + review + рассмотрение + No comment provided by engineer. + + + reviewed by admins + одобрен админами + No comment provided by engineer. + saved сохранено @@ -9341,11 +10131,6 @@ last received msg: %2$@ неизвестный статус No comment provided by engineer. - - unmute - уведомлять - No comment provided by engineer. - unprotected незащищённый @@ -9436,6 +10221,11 @@ last received msg: %2$@ Вы No comment provided by engineer. + + you accepted this member + Вы приняли этого члена + snd group event chat item + you are invited to group Вы приглашены в группу @@ -9510,7 +10300,7 @@ last received msg: %2$@
- +
@@ -9547,7 +10337,7 @@ last received msg: %2$@
- +
@@ -9569,7 +10359,7 @@ last received msg: %2$@
- +
@@ -9577,6 +10367,11 @@ last received msg: %2$@ %d новых сообщений notification body + + From %d chat(s) + Из %d чатов + notification body + From: %@ От: %@ @@ -9592,16 +10387,11 @@ last received msg: %2$@ Новые сообщения notification - - New messages in %d chats - Новые сообщения в %d разговоре(ах) - notification body -
- +
@@ -9623,7 +10413,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json index a28b0ed489..b49b25d653 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ru", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 4317787f67..528219b13a 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (สามารถคัดลอกได้) @@ -184,6 +172,10 @@ %d วินาที time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d ข้อความที่ถูกข้าม @@ -248,11 +240,6 @@ %lld new interface languages No comment provided by engineer. - - %lld second(s) - %lld วินาที - No comment provided by engineer. - %lld seconds %lld วินาที @@ -303,11 +290,6 @@ %u ข้อความที่ถูกข้าม No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) No comment provided by engineer. @@ -316,11 +298,6 @@ (this device v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. No comment provided by engineer. @@ -381,11 +358,6 @@ \*ตัวหนา* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -416,11 +388,6 @@ - ประวัติการแก้ไข No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec time to disappear @@ -433,7 +400,8 @@ 1 day 1 วัน - time interval + delete after time +time interval 1 hour @@ -448,12 +416,18 @@ 1 month 1 เดือน - time interval + delete after time +time interval 1 week 1 สัปดาห์ - time interval + delete after time +time interval + + + 1 year + delete after time 1-time link @@ -478,11 +452,6 @@ 30 วินาที No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -548,8 +517,16 @@ Accept รับ accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions @@ -568,7 +545,11 @@ Accept incognito ยอมรับโหมดไม่ระบุตัวตน accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions @@ -582,6 +563,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -595,6 +580,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile เพิ่มโปรไฟล์ @@ -619,6 +608,10 @@ เพิ่มเข้าไปในอุปกรณ์อื่น No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message เพิ่มข้อความต้อนรับ @@ -684,6 +677,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. ข้อมูลแอปทั้งหมดถูกลบแล้ว. @@ -694,6 +691,10 @@ แชทและข้อความทั้งหมดจะถูกลบ - การดำเนินการนี้ไม่สามารถยกเลิกได้! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. ข้อมูลทั้งหมดจะถูกลบเมื่อถูกป้อน @@ -729,6 +730,14 @@ All profiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. ผู้ติดต่อทั้งหมดของคุณจะยังคงเชื่อมต่ออยู่. @@ -800,6 +809,10 @@ อนุญาตให้ลบข้อความที่ส่งไปแล้วอย่างถาวร No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. No comment provided by engineer. @@ -876,6 +889,10 @@ โปรไฟล์แชทที่ว่างเปล่าพร้อมชื่อที่ให้ไว้ได้ถูกสร้างขึ้นและแอปจะเปิดตามปกติ No comment provided by engineer. + + Another reason + report reason + Answer call รับสาย @@ -899,6 +916,10 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon ไอคอนแอป @@ -941,6 +962,18 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -949,6 +982,18 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1052,6 +1097,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1069,6 +1118,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -1154,11 +1207,21 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). ตามโปรไฟล์แชท (ค่าเริ่มต้น) หรือ [โดยการเชื่อมต่อ](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (เบต้า) No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! สิ้นสุดการโทรแล้ว! @@ -1203,7 +1266,7 @@ Cancel ยกเลิก alert action - alert button +alert button Cancel migration @@ -1236,6 +1299,10 @@ เปลี่ยน No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1284,7 +1351,7 @@ Change self-destruct passcode เปลี่ยนรหัสผ่านแบบทำลายตัวเอง authentication reason - set passcode view +set passcode view Chat @@ -1374,11 +1441,23 @@ Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats แชท No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -1438,6 +1517,14 @@ ลบการสนทนา? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1455,6 +1542,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file เปรียบเทียบไฟล์ @@ -1485,14 +1576,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1510,6 +1593,10 @@ กำหนดค่าเซิร์ฟเวอร์ ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm ยืนยัน @@ -1555,6 +1642,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect เชื่อมต่อ @@ -1654,6 +1745,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error การเชื่อมต่อผิดพลาด @@ -1664,6 +1759,15 @@ This is your own one-time link! การเชื่อมต่อผิดพลาด (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1673,6 +1777,10 @@ This is your own one-time link! ส่งคําขอเชื่อมต่อแล้ว! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1746,6 +1854,10 @@ This is your own one-time link! ผู้ติดต่อสามารถทําเครื่องหมายข้อความเพื่อลบได้ คุณจะสามารถดูได้ No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue ดำเนินการต่อ @@ -1814,6 +1926,10 @@ This is your own one-time link! สร้างลิงค์ No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -2018,8 +2134,7 @@ This is your own one-time link! Delete ลบ alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2057,6 +2172,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile ลบโปรไฟล์แชท @@ -2067,6 +2186,10 @@ This is your own one-time link! ลบโปรไฟล์แชทไหม? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2144,6 +2267,10 @@ This is your own one-time link! ลบลิงค์ ไหม? No comment provided by engineer. + + Delete list? + alert title + Delete member message? ลบข้อความสมาชิก? @@ -2157,7 +2284,7 @@ This is your own one-time link! Delete messages ลบข้อความ - No comment provided by engineer. + alert button Delete messages after @@ -2193,6 +2320,10 @@ This is your own one-time link! ลบคิว server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2337,6 +2468,14 @@ This is your own one-time link! ปิดการใช้งาน SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all ปิดการใช้งานสำหรับทุกคน @@ -2419,6 +2558,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address อย่าสร้างที่อยู่ @@ -2429,9 +2572,17 @@ This is your own one-time link! อย่าเปิดใช้งาน No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again ไม่ต้องแสดงอีก + alert action + + + Done No comment provided by engineer. @@ -2442,7 +2593,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2511,8 +2662,8 @@ This is your own one-time link! เปิดใช้งาน (เก็บการแทนที่) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2528,7 +2679,7 @@ This is your own one-time link! Enable automatic message deletion? เปิดใช้งานการลบข้อความอัตโนมัติ? - No comment provided by engineer. + alert title Enable camera access @@ -2646,6 +2797,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode ใส่รหัสผ่าน @@ -2716,6 +2871,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการรับคำขอติดต่อ No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) เกิดข้อผิดพลาดในการเพิ่มสมาชิก @@ -2748,6 +2907,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -2767,6 +2930,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการสร้างลิงก์กลุ่ม No comment provided by engineer. + + Error creating list + alert title + Error creating member contact No comment provided by engineer. @@ -2780,6 +2947,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการสร้างโปรไฟล์! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file No comment provided by engineer. @@ -2789,6 +2960,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการลบฐานข้อมูลแชท No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! เกิดข้อผิดพลาดในการลบแชท! @@ -2882,10 +3057,18 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member เกิดข้อผิดพลาดในการลบสมาชิก - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -2896,6 +3079,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile เกิดข้อผิดพลาดในการบันทึกโปรไฟล์กลุ่ม @@ -2971,6 +3158,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการซิงโครไนซ์การเชื่อมต่อ No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link เกิดข้อผิดพลาดในการอัปเดตลิงก์กลุ่ม @@ -3011,7 +3202,9 @@ This is your own one-time link! Error: %@ ข้อผิดพลาด: % @ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3045,6 +3238,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database ส่งออกฐานข้อมูล @@ -3083,24 +3280,41 @@ This is your own one-time link! รวดเร็วและไม่ต้องรอจนกว่าผู้ส่งจะออนไลน์! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite ที่ชอบ swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3218,6 +3432,10 @@ This is your own one-time link! การแก้ไขไม่สนับสนุนโดยสมาชิกกลุ่ม No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3231,6 +3449,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -3335,6 +3557,10 @@ Error: %2$@ GIFs และสติกเกอร์ No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3436,11 +3662,19 @@ Error: %2$@ กลุ่มจะถูกลบสำหรับคุณ - ไม่สามารถยกเลิกได้! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help ความช่วยเหลือ No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden ซ่อนอยู่ @@ -3498,6 +3732,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to วิธี @@ -3630,6 +3868,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito ไม่ระบุตัวตน @@ -3718,6 +3964,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -3968,6 +4234,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ข้อความสด! @@ -4051,10 +4329,18 @@ This is your link for group %@! สมาชิก No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4078,6 +4364,10 @@ This is your link for group %@! สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. สมาชิกกลุ่มสามารถเพิ่มการแสดงปฏิกิริยาต่อข้อความได้ @@ -4088,6 +4378,10 @@ This is your link for group %@! สมาชิกกลุ่มสามารถลบข้อความที่ส่งแล้วอย่างถาวร No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -4112,6 +4406,10 @@ This is your link for group %@! สมาชิกกลุ่มสามารถส่งข้อความเสียง No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4209,6 +4507,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4297,6 +4599,10 @@ This is your link for group %@! กลั่นกรองที่: %@ copied message info + + More + swipe action + More improvements are coming soon! การปรับปรุงเพิ่มเติมกำลังจะมาเร็ว ๆ นี้! @@ -4322,7 +4628,11 @@ This is your link for group %@! Mute ปิดเสียง - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4369,6 +4679,10 @@ This is your link for group %@! สถานะเครือข่าย No comment provided by engineer. + + New + token status text + New Passcode รหัสผ่านใหม่ @@ -4427,6 +4741,10 @@ This is your link for group %@! บทบาทของสมาชิกใหม่ No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message ข้อความใหม่ @@ -4451,6 +4769,22 @@ This is your link for group %@! ไม่มีรหัสผ่านสำหรับแอป Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected ไม่ได้เลือกผู้ติดต่อ @@ -4497,6 +4831,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -4544,6 +4882,14 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. แพลตฟอร์มแรกที่ไม่มีตัวระบุผู้ใช้ - ถูกออกแบบให้เป็นส่วนตัว @@ -4553,6 +4899,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4571,10 +4921,18 @@ This is your link for group %@! ปิดการแจ้งเตือน! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4596,7 +4954,8 @@ This is your link for group %@! Ok ตกลง - alert button + alert action +alert button Old database @@ -4653,6 +5012,14 @@ Requires compatible VPN. เฉพาะเจ้าของกลุ่มเท่านั้นที่สามารถเปิดใช้งานข้อความเสียงได้ No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. มีเพียงคุณเท่านั้นที่สามารถแสดงปฏิกิริยาต่อข้อความได้ @@ -4705,7 +5072,7 @@ Requires compatible VPN. Open - No comment provided by engineer. + alert action Open Settings @@ -4734,6 +5101,10 @@ Requires compatible VPN. Open group No comment provided by engineer. + + Open link? + alert title + Open migration to another device authentication reason @@ -4774,6 +5145,10 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -4948,6 +5323,22 @@ Error: %@ โปรดจัดเก็บรหัสผ่านอย่างปลอดภัย คุณจะไม่สามารถเปลี่ยนรหัสผ่านได้หากคุณทำรหัสผ่านหาย No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface อินเตอร์เฟซภาษาโปแลนด์ @@ -4994,16 +5385,28 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined นิยามความเป็นส่วนตัวใหม่ No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames ชื่อไฟล์ส่วนตัว No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -5072,6 +5475,10 @@ Error: %@ ห้ามแสดงปฏิกิริยาต่อข้อความ No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5306,11 +5713,23 @@ Enable in *Network & servers* settings. ลดการใช้แบตเตอรี่ No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject ปฏิเสธ reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5321,6 +5740,10 @@ Enable in *Network & servers* settings. ปฏิเสธคำขอติดต่อ No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. ใช้เซิร์ฟเวอร์รีเลย์ในกรณีที่จำเป็นเท่านั้น บุคคลอื่นสามารถสังเกตที่อยู่ IP ของคุณได้ @@ -5399,6 +5822,50 @@ Enable in *Network & servers* settings. ตอบ chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required ที่จำเป็น @@ -5482,9 +5949,13 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5531,13 +6002,17 @@ Enable in *Network & servers* settings. Save บันทึก alert button - chat item action +chat item action Save (and notify contacts) บันทึก (และแจ้งผู้ติดต่อ) alert button + + Save admission settings? + alert title + Save and notify contact บันทึกและแจ้งผู้ติดต่อ @@ -5562,6 +6037,10 @@ Enable in *Network & servers* settings. บันทึกโปรไฟล์กลุ่ม No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat บันทึกรหัสผ่านและเปิดแชท @@ -5782,6 +6261,10 @@ Enable in *Network & servers* settings. ส่งการแจ้งเตือน No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas ส่งคําถามและความคิด @@ -5975,6 +6458,10 @@ Enable in *Network & servers* settings. ตั้ง 1 วัน No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… ตั้งชื่อผู้ติดต่อ… @@ -5994,6 +6481,14 @@ Enable in *Network & servers* settings. ตั้งแทนการรับรองความถูกต้องของระบบ No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode ตั้งรหัสผ่าน @@ -6035,7 +6530,7 @@ Enable in *Network & servers* settings. Share แชร์ alert action - chat item action +chat item action Share 1-time link @@ -6090,6 +6585,10 @@ Enable in *Network & servers* settings. แชร์กับผู้ติดต่อ No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6181,6 +6680,10 @@ Enable in *Network & servers* settings. SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address ที่อยู่ติดต่อ SimpleX @@ -6271,6 +6774,11 @@ Enable in *Network & servers* settings. ใครบางคน notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6350,6 +6858,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6403,6 +6915,10 @@ Enable in *Network & servers* settings. หมดเวลาการเชื่อมต่อ TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6465,13 +6981,17 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. การทดสอบล้มเหลวในขั้นตอน %@ server test failure + + Test notifications + No comment provided by engineer. + Test server เซิร์ฟเวอร์ทดสอบ @@ -6589,19 +7109,10 @@ It can happen because of some bug or when the connection is compromised.ฐานข้อมูลเก่าไม่ได้ถูกลบในระหว่างการย้ายข้อมูล แต่สามารถลบได้ No comment provided by engineer. - - The profile is only shared with your contacts. - โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -6660,6 +7171,10 @@ It can happen because of some bug or when the connection is compromised.การดำเนินการนี้ไม่สามารถเลิกทำได้ - ข้อความที่ส่งและรับก่อนหน้าที่เลือกไว้จะถูกลบ อาจใช้เวลาหลายนาที No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. การดำเนินการนี้ไม่สามารถยกเลิกได้ - โปรไฟล์ ผู้ติดต่อ ข้อความ และไฟล์ของคุณจะสูญหายไปอย่างถาวร @@ -6698,10 +7213,18 @@ It can happen because of some bug or when the connection is compromised.This is your own one-time link! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. การตั้งค่านี้ใช้กับข้อความในโปรไฟล์แชทปัจจุบันของคุณ **%@** @@ -6803,6 +7326,10 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -6955,13 +7482,17 @@ To connect, please ask your contact to create another connection link and check Unmute เปิดเสียง - swipe action + notification label action Unread เปลี่ยนเป็นยังไม่ได้อ่าน swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -6985,6 +7516,10 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. การอัปเดตการตั้งค่าจะเชื่อมต่อไคลเอนต์กับเซิร์ฟเวอร์ทั้งหมดอีกครั้ง @@ -7038,6 +7573,14 @@ To connect, please ask your contact to create another connection link and check ใช้เซิร์ฟเวอร์ SimpleX Chat ไหม? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat ใช้แชท @@ -7094,6 +7637,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. @@ -7102,6 +7649,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7433,10 +7984,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -7520,6 +8067,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! คุณไม่สามารถส่งข้อความได้! @@ -7595,6 +8146,10 @@ Repeat connection request? คุณส่งคำเชิญเข้าร่วมกลุ่มแล้ว No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! คุณจะเชื่อมต่อกับกลุ่มเมื่ออุปกรณ์โฮสต์ของกลุ่มออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! @@ -7657,11 +8212,6 @@ Repeat connection request? เซิร์ฟเวอร์ ICE ของคุณ No comment provided by engineer. - - Your SMP servers - เซิร์ฟเวอร์ SMP ของคุณ - No comment provided by engineer. - Your SimpleX address ที่อยู่ SimpleX ของคุณ @@ -7742,6 +8292,11 @@ Repeat connection request? Your profile **%@** will be shared. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. โปรไฟล์ของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณและแชร์กับผู้ติดต่อของคุณเท่านั้น เซิร์ฟเวอร์ SimpleX ไม่สามารถดูโปรไฟล์ของคุณได้ @@ -7751,11 +8306,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - โปรไฟล์ รายชื่อผู้ติดต่อ และข้อความที่ส่งของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณ - No comment provided by engineer. - Your random profile โปรไฟล์แบบสุ่มของคุณ @@ -7805,6 +8355,10 @@ Repeat connection request? ด้านบน จากนั้นเลือก: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call รับสายแล้ว @@ -7814,6 +8368,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin ผู้ดูแลระบบ @@ -7833,6 +8391,10 @@ Repeat connection request? เห็นด้วยกับการ encryption… chat item text + + all + member criteria value + all members feature role @@ -7846,6 +8408,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -7879,7 +8445,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -7905,6 +8472,10 @@ Repeat connection request? กำลังโทร… call status + + can't send messages + No comment provided by engineer. + cancelled %@ ยกเลิก %@ @@ -8008,6 +8579,14 @@ Repeat connection request? contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption ผู้ติดต่อมีการ encrypt จากต้นจนจบ @@ -8018,6 +8597,10 @@ Repeat connection request? ผู้ติดต่อไม่มีการ encrypt จากต้นจนจบ No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator ผู้สร้าง @@ -8045,7 +8628,8 @@ Repeat connection request? default (%@) ค่าเริ่มต้น (%@) - pref value + delete after time +pref value default (no) @@ -8169,18 +8753,10 @@ Repeat connection request? ผิดพลาด No comment provided by engineer. - - event happened - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8190,6 +8766,10 @@ Repeat connection request? ลบกลุ่มแล้ว No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated อัปเดตโปรไฟล์กลุ่มแล้ว @@ -8312,6 +8892,10 @@ Repeat connection request? เชื่อมต่อสำเร็จ rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8341,19 +8925,19 @@ Repeat connection request? กลั่นกรองโดย %@ marked deleted chat item preview text + + moderator + member role + months เดือน time unit - - mute - No comment provided by engineer. - never ไม่เคย - No comment provided by engineer. + delete after time new message @@ -8375,6 +8959,10 @@ Repeat connection request? ไม่มีข้อความ copied message info in history + + not synchronized + No comment provided by engineer. + observer ผู้สังเกตการณ์ @@ -8384,8 +8972,9 @@ Repeat connection request? off ปิด enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -8424,6 +9013,18 @@ Repeat connection request? เพื่อนต่อเพื่อน No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8438,6 +9039,10 @@ Repeat connection request? ได้รับการยืนยัน… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call สายถูกปฏิเสธ @@ -8457,6 +9062,10 @@ Repeat connection request? removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -8466,10 +9075,22 @@ Repeat connection request? ลบคุณออกแล้ว rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -8556,10 +9177,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. @@ -8644,6 +9261,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group คุณได้รับเชิญให้เข้าร่วมกลุ่ม @@ -8716,7 +9337,7 @@ last received msg: %2$@
- +
@@ -8752,7 +9373,7 @@ last received msg: %2$@
- +
@@ -8774,13 +9395,17 @@ last received msg: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -8793,15 +9418,11 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -8820,7 +9441,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/th.xcloc/contents.json b/apps/ios/SimpleX Localizations/th.xcloc/contents.json index 4562ab8385..ee6ee63ea9 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/th.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "th", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 261752aefc..d17a272016 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (kopyalanabilir) @@ -114,10 +102,12 @@ %@ server + %@ sunucu No comment provided by engineer. %@ servers + %@ sunucular No comment provided by engineer. @@ -200,6 +190,10 @@ %d saniye time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d okunmamış mesaj(lar) @@ -270,11 +264,6 @@ %lld yeni arayüz dilleri No comment provided by engineer. - - %lld second(s) - %lld saniye - No comment provided by engineer. - %lld seconds %lld saniye @@ -325,11 +314,6 @@ %u mesajlar atlandı. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (yeni) @@ -340,11 +324,6 @@ (bu cihaz v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Kişi ekle**: yeni bir davet bağlantısı oluşturmak için, ya da aldığın bağlantıyla bağlan. @@ -382,6 +361,7 @@ **Scan / Paste link**: to connect via a link you received. + edindiğiniz bağlantı aracılığıyla bağlanmak için **Linki tarayın/yapıştırın**. No comment provided by engineer. @@ -409,11 +389,6 @@ \*kalın* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -450,11 +425,6 @@ - düzenleme geçmişi. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 saniye @@ -468,7 +438,8 @@ 1 day 1 gün - time interval + delete after time +time interval 1 hour @@ -483,19 +454,27 @@ 1 month 1 ay - time interval + delete after time +time interval 1 week 1 hafta - time interval + delete after time +time interval + + + 1 year + delete after time 1-time link + tek kullanımlık bağlantı No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Tek kullanımlık bağlantı *sadece bir kişi ile* kullanılabilir - kişiyle veya uygulama içinden paylaş. No comment provided by engineer. @@ -513,11 +492,6 @@ 30 saniye No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -574,6 +548,7 @@ About operators + Operatörler hakkında No comment provided by engineer. @@ -585,11 +560,20 @@ Accept Kabul et accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions + Koşulları kabul et No comment provided by engineer. @@ -606,10 +590,15 @@ Accept incognito Takma adla kabul et accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions + Kabul edilmiş koşullar No comment provided by engineer. @@ -622,6 +611,10 @@ Onay hataları No comment provided by engineer. + + Active + token status text + Active connections Aktif bağlantılar @@ -634,6 +627,11 @@ Add friends + Arkadaş ekle + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -653,6 +651,7 @@ Add team members + Takım üyesi ekle No comment provided by engineer. @@ -660,6 +659,10 @@ Başka bir cihaza ekle No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Karşılama mesajı ekleyin @@ -667,14 +670,17 @@ Add your team members to the conversations. + Takım üyelerini konuşmalara ekle. No comment provided by engineer. Added media & file servers + medya ve dosya sunucuları eklendi No comment provided by engineer. Added message servers + Mesaj sunucuları eklendi No comment provided by engineer. @@ -704,10 +710,12 @@ Address or 1-time link? + adres mi yoksa tek kullanımlık bağlantı mı? No comment provided by engineer. Address settings + Adres seçenekleri No comment provided by engineer. @@ -730,6 +738,10 @@ Gelişmiş ayarlar No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Tüm uygulama verileri silinir. @@ -740,6 +752,10 @@ Tüm konuşmalar ve mesajlar silinecektir. Bu, geri alınamaz! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. Kullanıldığında bütün veriler silinir. @@ -757,6 +773,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte. No comment provided by engineer. @@ -779,6 +796,14 @@ Tüm Profiller profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Konuştuğun kişilerin tümü bağlı kalacaktır. @@ -854,6 +879,10 @@ Gönderilen mesajların kalıcı olarak silinmesine izin ver. (24 saat içinde) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. SimpleX bağlantıları göndilmesine izin ver. @@ -934,6 +963,10 @@ Verilen adla boş bir sohbet profili oluşturulur ve uygulama her zamanki gibi açılır. No comment provided by engineer. + + Another reason + report reason + Answer call Aramayı cevapla @@ -959,6 +992,10 @@ Uygulama yerel dosyaları şifreler (videolar dışında). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Uygulama simgesi @@ -1004,6 +1041,18 @@ Şuna uygula No comment provided by engineer. + + Archive + No comment provided by engineer. + + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Arşivle ve yükle @@ -1014,6 +1063,18 @@ Daha sonra görüşmek için kişileri arşivleyin. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + + + Archive reports + swipe action + Archived contacts Arşivli kişiler @@ -1124,6 +1185,10 @@ Daha iyi gruplar No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Daha iyi mesaj tarihleri. @@ -1144,6 +1209,10 @@ Daha iyi bildirimler No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Daha iyi güvenlik ✅ @@ -1236,10 +1305,16 @@ Business address + İş adresi No comment provided by engineer. Business chats + İş konuşmaları + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1247,6 +1322,12 @@ Sohbet profiline göre (varsayılan) veya [bağlantıya göre](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Arama çoktan bitti! @@ -1296,7 +1377,7 @@ Cancel İptal et alert action - alert button +alert button Cancel migration @@ -1333,8 +1414,13 @@ Değiştir No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Sohbet profillerini değiştir authentication reason @@ -1381,18 +1467,21 @@ Change self-destruct passcode Kendini yok eden parolayı değiştir authentication reason - set passcode view +set passcode view Chat + Sohbet No comment provided by engineer. Chat already exists + Sohbet zaten mevcut No comment provided by engineer. Chat already exists! + Sohbet zaten mevcut! No comment provided by engineer. @@ -1472,10 +1561,20 @@ Chat will be deleted for all members - this cannot be undone! + Sohbet bütün üyeler için silinecek - bu geri alınamaz! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Sohbet senden silinecek - bu geri alınamaz! + No comment provided by engineer. + + + Chat with admins + chat toolbar + + + Chat with member No comment provided by engineer. @@ -1483,12 +1582,18 @@ Sohbetler No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. + Her 20 dakikada mesajları kontrol et. No comment provided by engineer. Check messages when allowed. + İzin verildiğinde mesajları kontrol et. No comment provided by engineer. @@ -1546,6 +1651,14 @@ Sohbet temizlensin mi? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Gizli notlar temizlensin mi? @@ -1566,6 +1679,10 @@ Renk modu No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Dosya karşılaştır @@ -1583,38 +1700,37 @@ Conditions accepted on: %@. + Şuradaki koşullar kabul edildi: %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Koşullar operatör(ler) için kabul edildi: **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**. No comment provided by engineer. Conditions of use - No comment provided by engineer. - - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. + Kullanım koşulları No comment provided by engineer. Conditions will be accepted for the operator(s): **%@**. + Koşullar bu operatör(ler) için kabul edilecektir: **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Koşullar şu tarihte kabul edilecektir: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Koşullar etkin operatörler için şu tarihte otomatik olarak kabul edilecektir: %@. No comment provided by engineer. @@ -1622,6 +1738,10 @@ ICE sunucularını ayarla No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Onayla @@ -1672,6 +1792,10 @@ Yüklemeyi onayla No comment provided by engineer. + + Confirmed + token status text + Connect Bağlan @@ -1791,6 +1915,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı ve sunucuların durumu. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Bağlantı hatası @@ -1801,6 +1929,15 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı hatası (DOĞRULAMA) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Bağlantı bildirimleri @@ -1811,8 +1948,13 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı daveti gönderildi! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security + Bağlantı güvenliği No comment provided by engineer. @@ -1890,6 +2032,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kişiler silinmesi için mesajları işaretleyebilir; onları görüntüleyebilirsin. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Devam et @@ -1932,6 +2078,7 @@ Bu senin kendi tek kullanımlık bağlantın! Create 1-time link + Tek kullanımlık bağlantı oluştur No comment provided by engineer. @@ -1964,6 +2111,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı oluştur No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 [bilgisayar uygulaması] nda yeni bir profil oluştur(https://simplex.chat/downloads/). 💻 @@ -2021,6 +2172,7 @@ Bu senin kendi tek kullanımlık bağlantın! Current conditions text couldn't be loaded, you can review conditions via this link: + Şu anki koşulların yazısı yüklenemiyor, bu bağlantıdan koşullara inceleyebilirsin: No comment provided by engineer. @@ -2180,8 +2332,7 @@ Bu senin kendi tek kullanımlık bağlantın! Delete Sil alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2220,6 +2371,11 @@ Bu senin kendi tek kullanımlık bağlantın! Delete chat + Sohbeti sil + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2232,8 +2388,13 @@ Bu senin kendi tek kullanımlık bağlantın! Sohbet profili silinsin mi? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? + Sohbet silinsin mi? No comment provided by engineer. @@ -2311,6 +2472,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı silinsin mi? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Kişinin mesajı silinsin mi? @@ -2324,7 +2489,7 @@ Bu senin kendi tek kullanımlık bağlantın! Delete messages Mesajları sil - No comment provided by engineer. + alert button Delete messages after @@ -2361,6 +2526,10 @@ Bu senin kendi tek kullanımlık bağlantın! Sırayı sil server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Tek seferde en fazla 20 mesaj silin. @@ -2502,6 +2671,7 @@ Bu senin kendi tek kullanımlık bağlantın! Direct messages between members are prohibited in this chat. + Üyeler arası doğrudan mesajlar bu sohbette yasaktır. No comment provided by engineer. @@ -2519,6 +2689,14 @@ Bu senin kendi tek kullanımlık bağlantın! SimpleX Kilidini devre dışı bırak authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Herkes için devre dışı bırak @@ -2609,6 +2787,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kimlik bilgilerini proxy ile kullanmayın. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Adres oluşturma @@ -2619,9 +2801,17 @@ Bu senin kendi tek kullanımlık bağlantın! Etkinleştirme No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Yeniden gösterme + alert action + + + Done No comment provided by engineer. @@ -2633,7 +2823,7 @@ Bu senin kendi tek kullanımlık bağlantın! Download İndir alert button - chat item action +chat item action Download errors @@ -2687,6 +2877,7 @@ Bu senin kendi tek kullanımlık bağlantın! E2E encrypted notifications. + Uçtan uca şifrelenmiş bildirimler. No comment provided by engineer. @@ -2709,8 +2900,8 @@ Bu senin kendi tek kullanımlık bağlantın! Etkinleştir (geçersiz kılmaları koru) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2726,7 +2917,7 @@ Bu senin kendi tek kullanımlık bağlantın! Enable automatic message deletion? Otomatik mesaj silme etkinleştirilsin mi? - No comment provided by engineer. + alert title Enable camera access @@ -2853,6 +3044,10 @@ Bu senin kendi tek kullanımlık bağlantın! Şifreleme yeniden anlaşma başarısız oldu. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Şifre gir @@ -2920,6 +3115,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error accepting conditions + Koşulları kabul ederken hata oluştu alert title @@ -2927,6 +3123,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı isteği kabul edilirken hata oluştu No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Üye(ler) eklenirken hata oluştu @@ -2934,6 +3134,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error adding server + Sunucu eklenirken hata oluştu alert title @@ -2961,6 +3162,10 @@ Bu senin kendi tek kullanımlık bağlantın! Gizli moduna geçerken hata oluştu! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Yönlendirme sunucusu %@'ya bağlanırken hata oluştu. Lütfen daha sonra deneyin. @@ -2981,6 +3186,10 @@ Bu senin kendi tek kullanımlık bağlantın! Grup bağlantısı oluşturulurken hata oluştu No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Kişi iletişimi oluşturulurken hata oluştu @@ -2996,6 +3205,10 @@ Bu senin kendi tek kullanımlık bağlantın! Profil oluşturulurken hata oluştu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Dosya şifresi çözülürken hata oluştu @@ -3006,6 +3219,10 @@ Bu senin kendi tek kullanımlık bağlantın! Sohbet veritabanı silinirken sorun oluştu No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Sohbet silinirken hata oluştu! @@ -3078,6 +3295,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error loading servers + Sunucular yüklenirken hata oluştu alert title @@ -3105,10 +3323,18 @@ Bu senin kendi tek kullanımlık bağlantın! Hata sunuculara yeniden bağlanılıyor No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Kişiyi silerken sorun oluştu - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -3120,6 +3346,10 @@ Bu senin kendi tek kullanımlık bağlantın! ICE sunucularını kaydedirken sorun oluştu No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Grup profili kaydedilirken sorun oluştu @@ -3137,6 +3367,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error saving servers + Sunucular kaydedilirken hata oluştu alert title @@ -3199,6 +3430,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı senkronizasyonunda hata oluştu No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Grup bağlantısı güncellenirken hata oluştu @@ -3211,6 +3446,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error updating server + Sunucu güncellenirken hata oluştu alert title @@ -3241,7 +3477,9 @@ Bu senin kendi tek kullanımlık bağlantın! Error: %@ Hata: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3260,6 +3498,7 @@ Bu senin kendi tek kullanımlık bağlantın! Errors in servers configuration. + Sunucular yapılandırılırken hatalar oluştu. servers error @@ -3277,6 +3516,10 @@ Bu senin kendi tek kullanımlık bağlantın! Genişlet chat item action + + Expired + token status text + Export database Veritabanını dışarı aktar @@ -3317,20 +3560,32 @@ Bu senin kendi tek kullanımlık bağlantın! Hızlı ve gönderici çevrimiçi olana kadar beklemek yok! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Daha hızlı katılma ve daha güvenilir mesajlar. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Favori swipe action + + Favorites + No comment provided by engineer. + File error Dosya hatası - No comment provided by engineer. + file error alert title File errors: @@ -3339,6 +3594,11 @@ Bu senin kendi tek kullanımlık bağlantın! %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. Dosya bulunamadı - muhtemelen dosya silindi veya göderim iptal edildi. @@ -3464,8 +3724,13 @@ Bu senin kendi tek kullanımlık bağlantın! Düzeltme grup üyesi tarafından desteklenmiyor No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: + Sohbet profili için %@: servers error @@ -3475,14 +3740,21 @@ Bu senin kendi tek kullanımlık bağlantın! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir. + No comment provided by engineer. + + + For me No comment provided by engineer. For private routing + Gizli yönlendirme için No comment provided by engineer. For social media + Sosyal medya için No comment provided by engineer. @@ -3599,6 +3871,10 @@ Hata: %2$@ GİFler ve çıkartmalar No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! İyi öğlenler! @@ -3704,11 +3980,19 @@ Hata: %2$@ Grup senden silinecektir - bu geri alınamaz! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Yardım No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Gizlenmiş @@ -3761,12 +4045,18 @@ Hata: %2$@ How it affects privacy + Gizliliğinizi nasıl etkiler No comment provided by engineer. How it helps privacy + Gizliliğinizi nasıl arttırır No comment provided by engineer. + + How it works + alert button + How to Nasıl yapılır @@ -3909,6 +4199,14 @@ Daha fazla iyileştirme yakında geliyor! Arama içi sesler No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Gizli @@ -4001,6 +4299,26 @@ Daha fazla iyileştirme yakında geliyor! Arayüz renkleri No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Geçersiz QR kodu @@ -4063,6 +4381,7 @@ Daha fazla iyileştirme yakında geliyor! Invite to chat + Sohbete davet et No comment provided by engineer. @@ -4225,10 +4544,12 @@ Bu senin grup için bağlantın %@! Leave chat + Sohbetten ayrıl No comment provided by engineer. Leave chat? + Sohbetten ayrılsın mı? No comment provided by engineer. @@ -4271,6 +4592,18 @@ Bu senin grup için bağlantın %@! Bağlanmış bilgisayarlar No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Canlı mesaj! @@ -4356,13 +4689,22 @@ Bu senin grup için bağlantın %@! Kişi No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Üye inaktif item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. + Üye rolü "%@" olarak değiştirilecektir. Tüm sohbet üyeleri bilgilendirilecektir. No comment provided by engineer. @@ -4384,6 +4726,10 @@ Bu senin grup için bağlantın %@! Üye gruptan çıkarılacaktır - bu geri alınamaz! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Grup üyeleri mesaj tepkileri ekleyebilir. @@ -4394,6 +4740,10 @@ Bu senin grup için bağlantın %@! Grup üyeleri, gönderilen mesajları kalıcı olarak silebilir. (24 saat içinde) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Grup üyeleri SimpleX bağlantıları gönderebilir. @@ -4419,6 +4769,10 @@ Bu senin grup için bağlantın %@! Grup üyeleri sesli mesajlar gönderebilir. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menüler @@ -4529,6 +4883,10 @@ Bu senin grup için bağlantın %@! %@ den gelen mesajlar gösterilecektir! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Mesajlar alındı @@ -4629,6 +4987,10 @@ Bu senin grup için bağlantın %@! %@ de yönetildi copied message info + + More + swipe action + More improvements are coming soon! Daha fazla geliştirmeler yakında geliyor! @@ -4656,7 +5018,11 @@ Bu senin grup için bağlantın %@! Mute Sustur - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4706,6 +5072,10 @@ Bu senin grup için bağlantın %@! Ağ durumu No comment provided by engineer. + + New + token status text + New Passcode Yeni şifre @@ -4770,6 +5140,10 @@ Bu senin grup için bağlantın %@! Yeni üye rolü No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Yeni mesaj @@ -4794,6 +5168,22 @@ Bu senin grup için bağlantın %@! Uygulama şifresi yok Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Hiçbir kişi seçilmedi @@ -4843,6 +5233,10 @@ Bu senin grup için bağlantın %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -4893,6 +5287,14 @@ Bu senin grup için bağlantın %@! No servers to send files. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. Herhangi bir kullanıcı tanımlayıcısı yok. @@ -4903,6 +5305,10 @@ Bu senin grup için bağlantın %@! Uyumlu değil! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Hiçbir şey seçilmedi @@ -4923,10 +5329,18 @@ Bu senin grup için bağlantın %@! Bildirimler devre dışı! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -4949,7 +5363,8 @@ Bu senin grup için bağlantın %@! Ok Tamam - alert button + alert action +alert button Old database @@ -5009,6 +5424,14 @@ VPN'nin etkinleştirilmesi gerekir. Yalnızca grup sahipleri sesli mesajları etkinleştirebilir. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Sadece siz mesaj tepkileri ekleyebilirsiniz. @@ -5062,7 +5485,7 @@ VPN'nin etkinleştirilmesi gerekir. Open - No comment provided by engineer. + alert action Open Settings @@ -5092,6 +5515,10 @@ VPN'nin etkinleştirilmesi gerekir. Grubu aç No comment provided by engineer. + + Open link? + alert title + Open migration to another device Başka bir cihaza açık geçiş @@ -5138,6 +5565,10 @@ VPN'nin etkinleştirilmesi gerekir. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Diğer @@ -5329,6 +5760,22 @@ Hata: %@ Lütfen parolayı güvenli bir şekilde saklayın, kaybederseniz parolayı DEĞİŞTİREMEZSİNİZ. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Lehçe arayüz @@ -5377,16 +5824,28 @@ Hata: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Gizlilik yeniden tanımlandı No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Gizli dosya adları No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Gizli mesaj yönlendirme @@ -5462,6 +5921,10 @@ Hata: %@ Mesajlarda tepkileri yasakla. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. SimpleX bağlantısı gönderimini yasakla. @@ -5719,11 +6182,23 @@ Enable in *Network & servers* settings. Azaltılmış pil kullanımı No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Reddet reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5735,6 +6210,10 @@ Enable in *Network & servers* settings. Bağlanma isteğini reddet No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Yönlendirici sunucusu yalnızca gerekli olduğunda kullanılır. Başka bir taraf IP adresinizi gözlemleyebilir. @@ -5820,6 +6299,50 @@ Enable in *Network & servers* settings. Yanıtla chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required Gerekli @@ -5909,9 +6432,13 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -5962,13 +6489,17 @@ Enable in *Network & servers* settings. Save Kaydet alert button - chat item action +chat item action Save (and notify contacts) Kaydet (ve kişilere bildir) alert button + + Save admission settings? + alert title + Save and notify contact Kaydet ve kişilere bildir @@ -5994,6 +6525,10 @@ Enable in *Network & servers* settings. Grup profilini kaydet No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Parolayı kaydet ve sohbeti aç @@ -6234,6 +6769,10 @@ Enable in *Network & servers* settings. Bildirimler gönder No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Fikirler ve sorular gönderin @@ -6445,6 +6984,10 @@ Enable in *Network & servers* settings. 1 günlüğüne ayarla No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Kişi adı gir… @@ -6465,6 +7008,14 @@ Enable in *Network & servers* settings. Sistem kimlik doğrulaması yerine ayarla. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Şifre ayarla @@ -6509,7 +7060,7 @@ Enable in *Network & servers* settings. Share Paylaş alert action - chat item action +chat item action Share 1-time link @@ -6568,6 +7119,10 @@ Enable in *Network & servers* settings. Kişilerle paylaş No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code QR kodunu göster @@ -6665,6 +7220,10 @@ Enable in *Network & servers* settings. SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX kişi adresi @@ -6765,6 +7324,11 @@ Enable in *Network & servers* settings. Biri notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. Kare,daire, veya aralarında herhangi bir şey. @@ -6850,6 +7414,10 @@ Enable in *Network & servers* settings. Sohbeti durdurma No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Güçlü @@ -6910,6 +7478,10 @@ Enable in *Network & servers* settings. TCP bağlantı zaman aşımı No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6977,13 +7549,17 @@ Enable in *Network & servers* settings. Temporary file error Geçici dosya hatası - No comment provided by engineer. + file error alert title Test failed at step %@. Test %@ adımında başarısız oldu. server test failure + + Test notifications + No comment provided by engineer. + Test server Sunucuyu test et @@ -7104,19 +7680,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Eski veritabanı geçiş sırasında kaldırılmadı, silinebilir. No comment provided by engineer. - - The profile is only shared with your contacts. - Profil sadece kişilerinle paylaşılacak. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -7179,6 +7746,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Bu işlem geri alınamaz - seçilenden daha önce gönderilen ve alınan mesajlar silinecektir. Bu işlem birkaç dakika sürebilir. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Bu işlem geri alınamaz - profiliniz, kişileriniz, mesajlarınız ve dosyalarınız geri döndürülemez şekilde kaybolacaktır. @@ -7224,11 +7795,19 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Bu senin kendi tek kullanımlık bağlantın! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Bu bağlantı başka bir mobil cihazda kullanıldı, lütfen masaüstünde yeni bir bağlantı oluşturun. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Bu ayar, geçerli sohbet profiliniz **%@** deki mesajlara uygulanır. @@ -7337,6 +7916,10 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Bağlanırken gizli moda geçiş yap. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Araç çubuğu opaklığı @@ -7501,13 +8084,17 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Unmute Susturmayı kaldır - swipe action + notification label action Unread Okunmamış swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Yeni üyelere 100e kadar en son mesajlar gönderildi. @@ -7533,6 +8120,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Ayarları güncelleyelim mi? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Ayarların güncellenmesi, istemciyi tüm sunuculara yeniden bağlayacaktır. @@ -7592,6 +8183,14 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste SimpleX Chat sunucuları kullanılsın mı? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Sohbeti kullan @@ -7654,6 +8253,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Görüşme sırasında uygulamayı kullanın. @@ -7664,6 +8267,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Uygulamayı tek elle kullan. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Kullanıcı seçimi @@ -8032,10 +8639,6 @@ Katılma isteği tekrarlansın mı? Görünüm ayarlarından değiştirebilirsiniz. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8124,6 +8727,10 @@ Katılma isteği tekrarlansın mı? Bağlantı detaylarından davet bağlantısını yeniden görüntüleyebilirsin. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Mesajlar gönderemezsiniz! @@ -8206,6 +8813,10 @@ Bağlantı isteği tekrarlansın mı? Grup daveti gönderdiniz No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Grup sahibinin cihazı çevrimiçi olduğunda gruba bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin! @@ -8270,11 +8881,6 @@ Bağlantı isteği tekrarlansın mı? ICE sunucularınız No comment provided by engineer. - - Your SMP servers - SMP sunucularınız - No comment provided by engineer. - Your SimpleX address SimpleX adresin @@ -8360,6 +8966,11 @@ Bağlantı isteği tekrarlansın mı? Profiliniz **%@** paylaşılacaktır. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Profil sadece kişilerinle paylaşılacak. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Profiliniz cihazınızda saklanır ve sadece kişilerinizle paylaşılır. SimpleX sunucuları profilinizi göremez. @@ -8370,11 +8981,6 @@ Bağlantı isteği tekrarlansın mı? Profiliniz değiştirildi. Kaydederseniz, güncellenmiş profil tüm kişilerinize gönderilecektir. alert message - - Your profile, contacts and delivered messages are stored on your device. - Profiliniz, kişileriniz ve gönderilmiş mesajlar cihazınızda saklanır. - No comment provided by engineer. - Your random profile Rasgele profiliniz @@ -8424,6 +9030,10 @@ Bağlantı isteği tekrarlansın mı? yukarı çıkın, ardından seçin: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call kabul edilen arama @@ -8433,6 +9043,10 @@ Bağlantı isteği tekrarlansın mı? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin yönetici @@ -8453,6 +9067,10 @@ Bağlantı isteği tekrarlansın mı? şifreleme kabul ediliyor… chat item text + + all + member criteria value + all members bütün üyeler @@ -8468,6 +9086,10 @@ Bağlantı isteği tekrarlansın mı? ve %lld diğer etkinlikler No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts denemeler @@ -8506,7 +9128,8 @@ Bağlantı isteği tekrarlansın mı? blocked by admin yönetici tarafından engellendi - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8533,6 +9156,10 @@ Bağlantı isteği tekrarlansın mı? aranıyor… call status + + can't send messages + No comment provided by engineer. + cancelled %@ %@ iptal edildi @@ -8638,6 +9265,14 @@ Bağlantı isteği tekrarlansın mı? %1$@ kişisi %2$@ olarak değişti profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kişi uçtan uca şifrelemeye sahiptir @@ -8648,6 +9283,10 @@ Bağlantı isteği tekrarlansın mı? kişi uçtan uca şifrelemeye sahip değildir No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator oluşturan @@ -8676,7 +9315,8 @@ Bağlantı isteği tekrarlansın mı? default (%@) varsayılan (%@) - pref value + delete after time +pref value default (no) @@ -8803,20 +9443,11 @@ Bağlantı isteği tekrarlansın mı? hata No comment provided by engineer. - - event happened - etkinlik yaşandı - No comment provided by engineer. - expired Süresi dolmuş No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded iletildi @@ -8827,6 +9458,10 @@ Bağlantı isteği tekrarlansın mı? grup silindi No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated grup profili güncellendi @@ -8952,6 +9587,10 @@ Bağlantı isteği tekrarlansın mı? bağlanıldı rcv group event chat item + + member has old version + No comment provided by engineer. + message mesaj @@ -8982,20 +9621,19 @@ Bağlantı isteği tekrarlansın mı? %@ tarafından yönetilmekte marked deleted chat item preview text + + moderator + member role + months aylar time unit - - mute - Sessiz - No comment provided by engineer. - never asla - No comment provided by engineer. + delete after time new message @@ -9017,6 +9655,10 @@ Bağlantı isteği tekrarlansın mı? metin yok copied message info in history + + not synchronized + No comment provided by engineer. + observer gözlemci @@ -9026,8 +9668,9 @@ Bağlantı isteği tekrarlansın mı? off kapalı enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9069,6 +9712,18 @@ Bağlantı isteği tekrarlansın mı? eşler arası No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption kuantuma dayanıklı e2e şifreleme @@ -9084,6 +9739,10 @@ Bağlantı isteği tekrarlansın mı? onaylama alındı… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call geri çevrilmiş çağrı @@ -9104,6 +9763,10 @@ Bağlantı isteği tekrarlansın mı? kişi adresi silindi profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profil fotoğrafı silindi @@ -9114,10 +9777,22 @@ Bağlantı isteği tekrarlansın mı? sen kaldırıldın rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved kaydedildi @@ -9217,11 +9892,6 @@ son alınan msj: %2$@ bilinmeyen durum No comment provided by engineer. - - unmute - susturmayı kaldır - No comment provided by engineer. - unprotected korumasız @@ -9312,6 +9982,10 @@ son alınan msj: %2$@ sen No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group gruba davet edildiniz @@ -9386,7 +10060,7 @@ son alınan msj: %2$@
- +
@@ -9423,7 +10097,7 @@ son alınan msj: %2$@
- +
@@ -9445,13 +10119,17 @@ son alınan msj: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9464,15 +10142,11 @@ son alınan msj: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -9494,7 +10168,7 @@ son alınan msj: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/contents.json b/apps/ios/SimpleX Localizations/tr.xcloc/contents.json index 6f74640a6b..2e32ea2080 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/tr.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "tr", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index d7dcc58dcd..687393cfab 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (можна скопіювати) @@ -202,6 +190,10 @@ %d сек time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропущено повідомлення(ь) @@ -272,11 +264,6 @@ %lld нові мови інтерфейсу No comment provided by engineer. - - %lld second(s) - %lld секунд(и) - No comment provided by engineer. - %lld seconds %lld секунд @@ -327,11 +314,6 @@ %u повідомлень пропущено. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (новий) @@ -342,11 +324,6 @@ (цей пристрій v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Додати контакт**: створити нове посилання-запрошення. @@ -412,11 +389,6 @@ \*жирний* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -453,11 +425,6 @@ - історія редагування. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 сек @@ -471,7 +438,8 @@ 1 day 1 день - time interval + delete after time +time interval 1 hour @@ -486,12 +454,18 @@ 1 month 1 місяць - time interval + delete after time +time interval 1 week 1 тиждень - time interval + delete after time +time interval + + + 1 year + delete after time 1-time link @@ -518,11 +492,6 @@ 30 секунд No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -579,6 +548,7 @@ About operators + Про операторів No comment provided by engineer. @@ -590,8 +560,16 @@ Accept Прийняти accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions @@ -612,7 +590,11 @@ Accept incognito Прийняти інкогніто accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions @@ -629,6 +611,10 @@ Помилки підтвердження No comment provided by engineer. + + Active + token status text + Active connections Активні з'єднання @@ -641,6 +627,11 @@ Add friends + Додайте друзів + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -660,6 +651,7 @@ Add team members + Додайте учасників команди No comment provided by engineer. @@ -667,6 +659,10 @@ Додати до іншого пристрою No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Додати вітальне повідомлення @@ -674,6 +670,7 @@ Add your team members to the conversations. + Додайте членів своєї команди до розмов. No comment provided by engineer. @@ -741,6 +738,10 @@ Додаткові налаштування No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Всі дані програми видаляються. @@ -751,6 +752,10 @@ Всі чати та повідомлення будуть видалені - це неможливо скасувати! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + alert message + All data is erased when it is entered. Всі дані стираються при введенні. @@ -791,6 +796,14 @@ Всі профілі profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + + + All servers + No comment provided by engineer. + All your contacts will remain connected. Всі ваші контакти залишаться на зв'язку. @@ -866,6 +879,10 @@ Дозволяє безповоротно видаляти надіслані повідомлення. (24 години) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Дозволити надсилати посилання SimpleX. @@ -946,6 +963,10 @@ Створюється порожній профіль чату з вказаним ім'ям, і додаток відкривається у звичайному режимі. No comment provided by engineer. + + Another reason + report reason + Answer call Відповісти на дзвінок @@ -971,6 +992,10 @@ Додаток шифрує нові локальні файли (крім відео). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Іконка програми @@ -1016,6 +1041,18 @@ Звертатися до No comment provided by engineer. + + Archive + No comment provided by engineer. + + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Архівування та завантаження @@ -1026,6 +1063,18 @@ Архівуйте контакти, щоб поспілкуватися пізніше. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + + + Archive reports + swipe action + Archived contacts Архівні контакти @@ -1136,6 +1185,10 @@ Кращі групи No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Кращі дати повідомлень. @@ -1156,6 +1209,10 @@ Кращі сповіщення No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Краща безпека ✅ @@ -1248,10 +1305,16 @@ Business address + Адреса підприємства No comment provided by engineer. Business chats + Ділові чати + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1259,6 +1322,12 @@ Через профіль чату (за замовчуванням) або [за з'єднанням](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Дзвінок вже закінчився! @@ -1308,7 +1377,7 @@ Cancel Скасувати alert action - alert button +alert button Cancel migration @@ -1345,6 +1414,10 @@ Зміна No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Зміна профілів користувачів @@ -1394,18 +1467,21 @@ Change self-destruct passcode Змінити пароль самознищення authentication reason - set passcode view +set passcode view Chat + Чат No comment provided by engineer. Chat already exists + Чат вже існує No comment provided by engineer. Chat already exists! + Чат вже існує! No comment provided by engineer. @@ -1485,10 +1561,20 @@ Chat will be deleted for all members - this cannot be undone! + Чат буде видалено для всіх учасників - цю дію неможливо скасувати! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Чат буде видалено для вас - цю дію неможливо скасувати! + No comment provided by engineer. + + + Chat with admins + chat toolbar + + + Chat with member No comment provided by engineer. @@ -1496,6 +1582,10 @@ Чати No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Перевіряйте повідомлення кожні 20 хв. @@ -1561,6 +1651,14 @@ Відверта розмова? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Чисті приватні нотатки? @@ -1581,6 +1679,10 @@ Колірний режим No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Порівняти файл @@ -1616,16 +1718,6 @@ Умови використання No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Умови будуть прийняті для ввімкнених операторів через 30 днів. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Умови приймаються для оператора(ів): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Для оператора(ів) приймаються умови: **%@**. @@ -1646,6 +1738,10 @@ Налаштування серверів ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Підтвердити @@ -1696,6 +1792,10 @@ Підтвердити завантаження No comment provided by engineer. + + Confirmed + token status text + Connect Підключіться @@ -1815,6 +1915,10 @@ This is your own one-time link! Стан з'єднання та серверів. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Помилка підключення @@ -1825,6 +1929,15 @@ This is your own one-time link! Помилка підключення (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Сповіщення про підключення @@ -1835,6 +1948,10 @@ This is your own one-time link! Запит на підключення відправлено! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Безпека з'єднання @@ -1915,6 +2032,10 @@ This is your own one-time link! Контакти можуть позначати повідомлення для видалення; ви зможете їх переглянути. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продовжуйте @@ -1990,6 +2111,10 @@ This is your own one-time link! Створити посилання No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Створіть новий профіль у [desktop app](https://simplex.chat/downloads/). 💻 @@ -2207,8 +2332,7 @@ This is your own one-time link! Delete Видалити alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2247,6 +2371,11 @@ This is your own one-time link! Delete chat + Видалити чат + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2259,8 +2388,13 @@ This is your own one-time link! Видалити профіль чату? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? + Видалити чат? No comment provided by engineer. @@ -2338,6 +2472,10 @@ This is your own one-time link! Видалити посилання? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Видалити повідомлення учасника? @@ -2351,7 +2489,7 @@ This is your own one-time link! Delete messages Видалити повідомлення - No comment provided by engineer. + alert button Delete messages after @@ -2388,6 +2526,10 @@ This is your own one-time link! Видалити чергу server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Видаляйте до 20 повідомлень одночасно. @@ -2530,6 +2672,7 @@ This is your own one-time link! Direct messages between members are prohibited in this chat. + У цьому чаті заборонені прямі повідомлення між учасниками. No comment provided by engineer. @@ -2547,6 +2690,14 @@ This is your own one-time link! Вимкнути SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Вимкнути для всіх @@ -2637,6 +2788,10 @@ This is your own one-time link! Не використовуйте облікові дані з проксі. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не створювати адресу @@ -2647,9 +2802,17 @@ This is your own one-time link! Не вмикати No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Більше не показувати + alert action + + + Done No comment provided by engineer. @@ -2661,7 +2824,7 @@ This is your own one-time link! Download Завантажити alert button - chat item action +chat item action Download errors @@ -2738,9 +2901,8 @@ This is your own one-time link! Увімкнути (зберегти перевизначення) No comment provided by engineer. - - Enable Flux - Увімкнути Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -2756,7 +2918,7 @@ This is your own one-time link! Enable automatic message deletion? Увімкнути автоматичне видалення повідомлень? - No comment provided by engineer. + alert title Enable camera access @@ -2883,6 +3045,10 @@ This is your own one-time link! Повторне узгодження шифрування не вдалося. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Введіть пароль @@ -2958,6 +3124,10 @@ This is your own one-time link! Помилка при прийнятті запиту на контакт No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Помилка додавання користувача(ів) @@ -2993,6 +3163,10 @@ This is your own one-time link! Помилка переходу на інкогніто! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Помилка підключення до сервера переадресації %@. Спробуйте пізніше. @@ -3013,6 +3187,10 @@ This is your own one-time link! Помилка створення посилання на групу No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Помилка при створенні контакту користувача @@ -3028,6 +3206,10 @@ This is your own one-time link! Помилка створення профілю! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Помилка розшифрування файлу @@ -3038,6 +3220,10 @@ This is your own one-time link! Помилка видалення бази даних чату No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Помилка видалення чату! @@ -3138,10 +3324,18 @@ This is your own one-time link! Помилка перепідключення серверів No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Помилка видалення учасника - No comment provided by engineer. + alert title + + + Error reordering lists + alert title Error resetting statistics @@ -3153,6 +3347,10 @@ This is your own one-time link! Помилка збереження серверів ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Помилка збереження профілю групи @@ -3233,6 +3431,10 @@ This is your own one-time link! Помилка синхронізації з'єднання No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Помилка оновлення посилання на групу @@ -3276,7 +3478,9 @@ This is your own one-time link! Error: %@ Помилка: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3313,6 +3517,10 @@ This is your own one-time link! Розгорнути chat item action + + Expired + token status text + Export database Експорт бази даних @@ -3353,20 +3561,32 @@ This is your own one-time link! Швидко і без очікування, поки відправник буде онлайн! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Швидше приєднання та надійніші повідомлення. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Улюблений swipe action + + Favorites + No comment provided by engineer. + File error Помилка файлу - No comment provided by engineer. + file error alert title File errors: @@ -3375,6 +3595,11 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +%@. + file error text + File not found - most likely file was deleted or cancelled. Файл не знайдено - найімовірніше, файл було видалено або скасовано. @@ -3500,6 +3725,10 @@ This is your own one-time link! Виправлення не підтримується учасником групи No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Для профілю чату %@: @@ -3515,6 +3744,10 @@ This is your own one-time link! Наприклад, якщо ваш контакт отримує повідомлення через сервер SimpleX Chat, ваш додаток доставлятиме їх через сервер Flux. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Для приватної маршрутизації @@ -3639,6 +3872,10 @@ Error: %2$@ GIF-файли та наклейки No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Доброго дня! @@ -3744,11 +3981,19 @@ Error: %2$@ Група буде видалена для вас - це не може бути скасовано! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Довідка No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Приховано @@ -3809,6 +4054,10 @@ Error: %2$@ Як це захищає приватність No comment provided by engineer. + + How it works + alert button + How to Як зробити @@ -3951,6 +4200,14 @@ More improvements are coming soon! Звуки вхідного дзвінка No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Інкогніто @@ -4043,6 +4300,26 @@ More improvements are coming soon! Кольори інтерфейсу No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Неправильний QR-код @@ -4105,6 +4382,7 @@ More improvements are coming soon! Invite to chat + Запросити в чат No comment provided by engineer. @@ -4267,10 +4545,12 @@ This is your link for group %@! Leave chat + Вийти з чату No comment provided by engineer. Leave chat? + Залишити чат? No comment provided by engineer. @@ -4313,6 +4593,18 @@ This is your link for group %@! Пов'язані робочі столи No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Живе повідомлення! @@ -4398,13 +4690,22 @@ This is your link for group %@! Учасник No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Користувач неактивний item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. + Роль учасника буде змінено на "%@". Усі учасники чату отримають сповіщення. No comment provided by engineer. @@ -4419,6 +4720,7 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! + Учасника буде видалено з чату – це неможливо скасувати! No comment provided by engineer. @@ -4426,6 +4728,10 @@ This is your link for group %@! Учасник буде видалений з групи - це неможливо скасувати! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Учасники групи можуть додавати реакції на повідомлення. @@ -4436,6 +4742,10 @@ This is your link for group %@! Учасники групи можуть безповоротно видаляти надіслані повідомлення. (24 години) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Учасники групи можуть надсилати посилання SimpleX. @@ -4461,6 +4771,10 @@ This is your link for group %@! Учасники групи можуть надсилати голосові повідомлення. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Меню @@ -4571,6 +4885,10 @@ This is your link for group %@! Повідомлення від %@ будуть показані! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Отримані повідомлення @@ -4671,6 +4989,10 @@ This is your link for group %@! Модерується за: %@ copied message info + + More + swipe action + More improvements are coming soon! Незабаром буде ще більше покращень! @@ -4699,7 +5021,11 @@ This is your link for group %@! Mute Вимкнути звук - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4751,6 +5077,10 @@ This is your link for group %@! Стан мережі No comment provided by engineer. + + New + token status text + New Passcode Новий пароль @@ -4816,6 +5146,10 @@ This is your link for group %@! Нова роль учасника No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Нове повідомлення @@ -4841,6 +5175,22 @@ This is your link for group %@! Немає пароля програми Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected Не вибрано жодного контакту @@ -4891,6 +5241,10 @@ This is your link for group %@! Ніяких медіа та файлових серверів. servers error + + No message + No comment provided by engineer. + No message servers. Ніяких серверів повідомлень. @@ -4946,6 +5300,14 @@ This is your link for group %@! Немає серверів для надсилання файлів. servers error + + No token! + alert title + + + No unread chats + No comment provided by engineer. + No user identifiers. Ніяких ідентифікаторів користувачів. @@ -4956,6 +5318,10 @@ This is your link for group %@! Не сумісні! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Нічого не вибрано @@ -4976,11 +5342,19 @@ This is your link for group %@! Сповіщення вимкнено! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Сповіщення про приватність No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5003,7 +5377,8 @@ This is your link for group %@! Ok Гаразд - alert button + alert action +alert button Old database @@ -5036,6 +5411,7 @@ Requires compatible VPN. Only chat owners can change preferences. + Лише власники чату можуть змінювати налаштування. No comment provided by engineer. @@ -5063,6 +5439,14 @@ Requires compatible VPN. Тільки власники груп можуть вмикати голосові повідомлення. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Тільки ви можете додавати реакції на повідомлення. @@ -5116,7 +5500,7 @@ Requires compatible VPN. Open Відкрито - No comment provided by engineer. + alert action Open Settings @@ -5148,6 +5532,10 @@ Requires compatible VPN. Відкрита група No comment provided by engineer. + + Open link? + alert title + Open migration to another device Відкрита міграція на інший пристрій @@ -5170,6 +5558,7 @@ Requires compatible VPN. Or import archive file + Або імпортуйте архівний файл No comment provided by engineer. @@ -5197,6 +5586,10 @@ Requires compatible VPN. Або поділитися приватно No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Інше @@ -5388,6 +5781,22 @@ Error: %@ Будь ласка, зберігайте пароль надійно, ви НЕ зможете змінити його, якщо втратите. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Польський інтерфейс @@ -5435,6 +5844,11 @@ Error: %@ Privacy for your customers. + Конфіденційність для ваших клієнтів. + No comment provided by engineer. + + + Privacy policy and conditions of use. No comment provided by engineer. @@ -5442,11 +5856,19 @@ Error: %@ Конфіденційність переглянута No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Приватні імена файлів No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Маршрутизація приватних повідомлень @@ -5522,6 +5944,10 @@ Error: %@ Заборонити реакції на повідомлення. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Заборонити надсилання посилань SimpleX. @@ -5779,11 +6205,23 @@ Enable in *Network & servers* settings. Зменшення використання акумулятора No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Відхилити reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5795,6 +6233,10 @@ Enable in *Network & servers* settings. Відхилити запит на контакт No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Релейний сервер використовується тільки в разі потреби. Інша сторона може бачити вашу IP-адресу. @@ -5880,6 +6322,50 @@ Enable in *Network & servers* settings. Відповісти chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required Потрібно @@ -5970,10 +6456,13 @@ Enable in *Network & servers* settings. Умови перегляду No comment provided by engineer. - - Review later - Перегляньте пізніше - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke @@ -6024,13 +6513,17 @@ Enable in *Network & servers* settings. Save Зберегти alert button - chat item action +chat item action Save (and notify contacts) Зберегти (і повідомити контактам) alert button + + Save admission settings? + alert title + Save and notify contact Зберегти та повідомити контакт @@ -6056,6 +6549,10 @@ Enable in *Network & servers* settings. Зберегти профіль групи No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Збережіть пароль і відкрийте чат @@ -6296,6 +6793,10 @@ Enable in *Network & servers* settings. Надсилати сповіщення No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Надсилайте запитання та ідеї @@ -6511,6 +7012,10 @@ Enable in *Network & servers* settings. Встановити 1 день No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Встановити ім'я контакту… @@ -6531,6 +7036,14 @@ Enable in *Network & servers* settings. Встановіть його замість аутентифікації системи. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Встановити пароль @@ -6575,7 +7088,7 @@ Enable in *Network & servers* settings. Share Поділіться alert action - chat item action +chat item action Share 1-time link @@ -6637,6 +7150,10 @@ Enable in *Network & servers* settings. Поділіться з контактами No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Показати QR-код @@ -6694,6 +7211,7 @@ Enable in *Network & servers* settings. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat і Flux уклали угоду про включення серверів, керованих Flux, у додаток. No comment provided by engineer. @@ -6736,6 +7254,10 @@ Enable in *Network & servers* settings. SimpleX адреса або одноразове посилання? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Контактна адреса SimpleX @@ -6838,6 +7360,11 @@ Enable in *Network & servers* settings. Хтось notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. Квадрат, коло або щось середнє між ними. @@ -6923,6 +7450,10 @@ Enable in *Network & servers* settings. Зупинка чату No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Сильний @@ -6983,6 +7514,10 @@ Enable in *Network & servers* settings. Тайм-аут TCP-з'єднання No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7010,6 +7545,7 @@ Enable in *Network & servers* settings. Tap Create SimpleX address in the menu to create it later. + Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше. No comment provided by engineer. @@ -7050,13 +7586,17 @@ Enable in *Network & servers* settings. Temporary file error Тимчасова помилка файлу - No comment provided by engineer. + file error alert title Test failed at step %@. Тест завершився невдало на кроці %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Тестовий сервер @@ -7179,21 +7719,11 @@ It can happen because of some bug or when the connection is compromised.Стара база даних не була видалена під час міграції, її можна видалити. No comment provided by engineer. - - The profile is only shared with your contacts. - Профіль доступний лише вашим контактам. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Такі ж умови діятимуть і для оператора **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Такі ж умови будуть застосовуватися до оператора(ів): **%@**. - No comment provided by engineer. - The second preset operator in the app! Другий попередньо встановлений оператор у застосунку! @@ -7259,6 +7789,10 @@ It can happen because of some bug or when the connection is compromised.Цю дію неможливо скасувати - повідомлення, надіслані та отримані раніше, ніж вибрані, будуть видалені. Це може зайняти кілька хвилин. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Цю дію неможливо скасувати - ваш профіль, контакти, повідомлення та файли будуть безповоротно втрачені. @@ -7304,11 +7838,19 @@ It can happen because of some bug or when the connection is compromised.Це ваше власне одноразове посилання! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. Це посилання було використано з іншого мобільного пристрою, будь ласка, створіть нове посилання на робочому столі. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**. @@ -7421,6 +7963,10 @@ You will be prompted to complete authentication before this feature is enabled.< Увімкніть інкогніто при підключенні. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Непрозорість панелі інструментів @@ -7586,13 +8132,17 @@ To connect, please ask your contact to create another connection link and check Unmute Увімкнути звук - swipe action + notification label action Unread Непрочитане swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Новим користувачам надсилається до 100 останніх повідомлень. @@ -7618,6 +8168,10 @@ To connect, please ask your contact to create another connection link and check Оновити налаштування? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Оновлення налаштувань призведе до перепідключення клієнта до всіх серверів. @@ -7678,6 +8232,14 @@ To connect, please ask your contact to create another connection link and check Використовувати сервери SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Використовуйте чат @@ -7743,6 +8305,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте сервери No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Використовуйте додаток під час розмови. @@ -7753,6 +8319,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте додаток однією рукою. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Вибір користувача @@ -8060,6 +8630,7 @@ To connect, please ask your contact to create another connection link and check You are already connected with %@. + Ви вже підключені до %@. No comment provided by engineer. @@ -8124,11 +8695,6 @@ Repeat join request? Ви можете змінити його в налаштуваннях зовнішнього вигляду. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. - No comment provided by engineer. - You can configure servers via settings. Ви можете налаштувати сервери за допомогою налаштувань. @@ -8219,6 +8785,10 @@ Repeat join request? Ви можете переглянути посилання на запрошення ще раз у деталях підключення. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Ви не можете надсилати повідомлення! @@ -8301,6 +8871,10 @@ Repeat connection request? Ви надіслали запрошення до групи No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, будь ласка, зачекайте або перевірте пізніше! @@ -8338,6 +8912,7 @@ Repeat connection request? You will stop receiving messages from this chat. Chat history will be preserved. + Ви більше не будете отримувати повідомлення з цього чату. Історія чату буде збережена. No comment provided by engineer. @@ -8365,11 +8940,6 @@ Repeat connection request? Ваші сервери ICE No comment provided by engineer. - - Your SMP servers - Ваші SMP-сервери - No comment provided by engineer. - Your SimpleX address Ваша адреса SimpleX @@ -8455,6 +9025,11 @@ Repeat connection request? Ваш профіль **%@** буде опублікований. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + Профіль доступний лише вашим контактам. + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Ваш профіль зберігається на вашому пристрої і доступний лише вашим контактам. Сервери SimpleX не бачать ваш профіль. @@ -8465,11 +9040,6 @@ Repeat connection request? Ваш профіль було змінено. Якщо ви збережете його, оновлений профіль буде надіслано всім вашим контактам. alert message - - Your profile, contacts and delivered messages are stored on your device. - Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої. - No comment provided by engineer. - Your random profile Ваш випадковий профіль @@ -8520,6 +9090,10 @@ Repeat connection request? вище, а потім обирайте: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call прийнято виклик @@ -8527,8 +9101,13 @@ Repeat connection request? accepted invitation + прийняте запрошення chat list item title + + accepted you + rcv group event chat item + admin адмін @@ -8549,6 +9128,10 @@ Repeat connection request? узгодження шифрування… chat item text + + all + member criteria value + all members всі учасники @@ -8564,6 +9147,10 @@ Repeat connection request? та %lld інших подій No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts спроби @@ -8602,7 +9189,8 @@ Repeat connection request? blocked by admin заблоковано адміністратором - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8629,6 +9217,10 @@ Repeat connection request? дзвоніть… call status + + can't send messages + No comment provided by engineer. + cancelled %@ скасовано %@ @@ -8734,6 +9326,14 @@ Repeat connection request? контакт %1$@ змінено на %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption контакт має шифрування e2e @@ -8744,6 +9344,10 @@ Repeat connection request? контакт не має шифрування e2e No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator творець @@ -8772,7 +9376,8 @@ Repeat connection request? default (%@) за замовчуванням (%@) - pref value + delete after time +pref value default (no) @@ -8899,21 +9504,11 @@ Repeat connection request? помилка No comment provided by engineer. - - event happened - відбулася подія - No comment provided by engineer. - expired закінчився No comment provided by engineer. - - for better metadata privacy. - для кращої конфіденційності метаданих. - No comment provided by engineer. - forwarded переслано @@ -8924,6 +9519,10 @@ Repeat connection request? групу видалено No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated оновлено профіль групи @@ -9049,6 +9648,10 @@ Repeat connection request? з'єднаний rcv group event chat item + + member has old version + No comment provided by engineer. + message повідомлення @@ -9079,20 +9682,19 @@ Repeat connection request? модерується %@ marked deleted chat item preview text + + moderator + member role + months місяців time unit - - mute - приглушити - No comment provided by engineer. - never ніколи - No comment provided by engineer. + delete after time new message @@ -9114,6 +9716,10 @@ Repeat connection request? без тексту copied message info in history + + not synchronized + No comment provided by engineer. + observer спостерігач @@ -9123,8 +9729,9 @@ Repeat connection request? off вимкнено enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9166,6 +9773,18 @@ Repeat connection request? одноранговий No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption квантово-стійке шифрування e2e @@ -9181,6 +9800,10 @@ Repeat connection request? отримали підтвердження… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call відхилений виклик @@ -9201,6 +9824,10 @@ Repeat connection request? видалено контактну адресу profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture видалено зображення профілю @@ -9211,10 +9838,23 @@ Repeat connection request? прибрали вас rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect + запит на підключення chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved збережено @@ -9314,11 +9954,6 @@ last received msg: %2$@ невідомий статус No comment provided by engineer. - - unmute - увімкнути звук - No comment provided by engineer. - unprotected незахищені @@ -9409,6 +10044,10 @@ last received msg: %2$@ ти No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group вас запрошують до групи @@ -9483,7 +10122,7 @@ last received msg: %2$@
- +
@@ -9520,7 +10159,7 @@ last received msg: %2$@
- +
@@ -9542,7 +10181,7 @@ last received msg: %2$@
- +
@@ -9550,6 +10189,10 @@ last received msg: %2$@ %d нових подій notification body + + From %d chat(s) + notification body + From: %@ Від: %@ @@ -9565,16 +10208,11 @@ last received msg: %2$@ Нові повідомлення notification - - New messages in %d chats - Нові повідомлення в чатах %d - notification body -
- +
@@ -9596,7 +10234,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json index 38238e7802..a93c702952 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "uk", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index d6e548c6be..06ce8d4950 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -2,21 +2,9 @@
- +
- - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (可复制) @@ -114,10 +102,12 @@ %@ server + 服务器 No comment provided by engineer. %@ servers + 服务器 No comment provided by engineer. @@ -132,6 +122,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -156,18 +147,22 @@ %d file(s) are still being downloaded. + 仍在下载 %d 个文件。 forward confirmation reason %d file(s) failed to download. + %d 个文件下载失败。 forward confirmation reason %d file(s) were deleted. + 已刪除 %d 个文件。 forward confirmation reason %d file(s) were not downloaded. + 未能下载 %d 个文件。 forward confirmation reason @@ -177,6 +172,7 @@ %d messages not forwarded + 未转发 %d 条消息 alert title @@ -194,9 +190,14 @@ %d 秒 time interval + + %d seconds(s) + %d 秒 + delete after time + %d skipped message(s) - %d 跳过消息 + 跳过的 %d 条消息 integrity error chat item @@ -264,11 +265,6 @@ %lld 种新的界面语言 No comment provided by engineer. - - %lld second(s) - %lld 秒 - No comment provided by engineer. - %lld seconds %lld 秒 @@ -319,11 +315,6 @@ 已跳过 %u 条消息。 No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (新) @@ -334,11 +325,6 @@ (此设备 v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **添加联系人**: 创建新的邀请链接,或通过您收到的链接进行连接. @@ -376,6 +362,7 @@ **Scan / Paste link**: to connect via a link you received. + **扫描/粘贴链接**:用您收到的链接连接。 No comment provided by engineer. @@ -403,11 +390,6 @@ \*加粗* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -444,11 +426,6 @@ - 编辑消息历史。 No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 秒 @@ -462,7 +439,8 @@ 1 day 1天 - time interval + delete after time +time interval 1 hour @@ -477,19 +455,28 @@ 1 month 1月 - time interval + delete after time +time interval 1 week 1周 - time interval + delete after time +time interval + + + 1 year + 1 年 + delete after time 1-time link + 一次性链接 No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + 一次性链接*只能给一名联系人*使用。当面或使用聊天应用分享链接。 No comment provided by engineer. @@ -507,11 +494,6 @@ 30秒 No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -568,6 +550,7 @@ About operators + 关于运营方 No comment provided by engineer. @@ -579,11 +562,20 @@ Accept 接受 accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action + + + Accept as member + alert action + + + Accept as observer + alert action Accept conditions + 接受条款 No comment provided by engineer. @@ -600,10 +592,15 @@ Accept incognito 接受隐身聊天 accept contact request via notification - swipe action +swipe action + + + Accept member + alert title Accepted conditions + 已接受的条款 No comment provided by engineer. @@ -616,6 +613,11 @@ 确认错误 No comment provided by engineer. + + Active + 活跃 + token status text + Active connections 活动连接 @@ -628,6 +630,12 @@ Add friends + 添加好友 + No comment provided by engineer. + + + Add list + 添加列表 No comment provided by engineer. @@ -647,6 +655,7 @@ Add team members + 添加团队成员 No comment provided by engineer. @@ -654,6 +663,11 @@ 添加另一设备 No comment provided by engineer. + + Add to list + 添加到列表 + No comment provided by engineer. + Add welcome message 添加欢迎信息 @@ -661,14 +675,17 @@ Add your team members to the conversations. + 将你的团队成员加入对话。 No comment provided by engineer. Added media & file servers + 已添加媒体和文件服务器 No comment provided by engineer. Added message servers + 已添加消息服务器 No comment provided by engineer. @@ -698,10 +715,12 @@ Address or 1-time link? + 地址还是一次性链接? No comment provided by engineer. Address settings + 地址设置 No comment provided by engineer. @@ -724,6 +743,11 @@ 高级设置 No comment provided by engineer. + + All + 全部 + No comment provided by engineer. + All app data is deleted. 已删除所有应用程序数据。 @@ -734,6 +758,11 @@ 所有聊天记录和消息将被删除——这一行为无法撤销! No comment provided by engineer. + + All chats will be removed from the list %@, and the list deleted. + 列表 %@ 和其中全部聊天将被删除。 + alert message + All data is erased when it is entered. 所有数据在输入后将被删除。 @@ -751,6 +780,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + 所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。 No comment provided by engineer. @@ -773,6 +803,16 @@ 所有配置文件 profile dropdown + + All reports will be archived for you. + 将为你存档所有举报。 + No comment provided by engineer. + + + All servers + 全部服务器 + No comment provided by engineer. + All your contacts will remain connected. 所有联系人会保持连接。 @@ -848,6 +888,11 @@ 允许不可撤回地删除已发送消息 No comment provided by engineer. + + Allow to report messsages to moderators. + 允许向 moderators 举报消息。 + No comment provided by engineer. + Allow to send SimpleX links. 允许发送 SimpleX 链接。 @@ -928,6 +973,11 @@ 已创建一个包含所提供名字的空白聊天资料,应用程序照常打开。 No comment provided by engineer. + + Another reason + 另一个理由 + report reason + Answer call 接听来电 @@ -953,6 +1003,11 @@ 应用程序为新的本地文件(视频除外)加密。 No comment provided by engineer. + + App group: + 应用组: + No comment provided by engineer. + App icon 应用程序图标 @@ -970,6 +1025,7 @@ App session + 应用会话 No comment provided by engineer. @@ -997,6 +1053,21 @@ 应用于 No comment provided by engineer. + + Archive + 存档 + No comment provided by engineer. + + + Archive %lld reports? + 存档 %lld 个举报? + No comment provided by engineer. + + + Archive all reports? + 存档所有举报? + No comment provided by engineer. + Archive and upload 存档和上传 @@ -1007,6 +1078,21 @@ 存档联系人以便稍后聊天. No comment provided by engineer. + + Archive report + 存档举报 + No comment provided by engineer. + + + Archive report? + 存档举报? + No comment provided by engineer. + + + Archive reports + 存档举报 + swipe action + Archived contacts 已存档的联系人 @@ -1079,6 +1165,7 @@ Auto-accept settings + 自动接受设置 alert title @@ -1108,6 +1195,7 @@ Better calls + 更佳的通话 No comment provided by engineer. @@ -1115,8 +1203,14 @@ 更佳的群组 No comment provided by engineer. + + Better groups performance + 更好的群性能 + No comment provided by engineer. + Better message dates. + 更好的消息日期。 No comment provided by engineer. @@ -1131,14 +1225,22 @@ Better notifications + 更佳的通知 + No comment provided by engineer. + + + Better privacy and security + 更好的隐私和安全 No comment provided by engineer. Better security ✅ + 更佳的安全性✅ No comment provided by engineer. Better user experience + 更佳的使用体验 No comment provided by engineer. @@ -1223,10 +1325,17 @@ Business address + 企业地址 No comment provided by engineer. Business chats + 企业聊天 + No comment provided by engineer. + + + Businesses + 企业 No comment provided by engineer. @@ -1234,6 +1343,15 @@ 通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。 No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + 使用 SimpleX Chat 代表您同意: +- 在公开群中只发送合法内容 +- 尊重其他用户 – 没有垃圾信息。 + No comment provided by engineer. + Call already ended! 通话已结束! @@ -1283,7 +1401,7 @@ Cancel 取消 alert action - alert button +alert button Cancel migration @@ -1320,8 +1438,14 @@ 更改 No comment provided by engineer. + + Change automatic message deletion? + 更改消息自动删除设置? + alert title + Change chat profiles + 更改聊天资料 authentication reason @@ -1368,18 +1492,21 @@ Change self-destruct passcode 更改自毁密码 authentication reason - set passcode view +set passcode view Chat + 聊天 No comment provided by engineer. Chat already exists + 聊天已存在 No comment provided by engineer. Chat already exists! + 聊天已存在! No comment provided by engineer. @@ -1444,6 +1571,7 @@ Chat preferences were changed. + 聊天偏好设置已修改。 alert message @@ -1458,10 +1586,20 @@ Chat will be deleted for all members - this cannot be undone! + 将为所有成员删除聊天 - 此操作无法撤销! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + 将为你删除聊天 - 此操作无法撤销! + No comment provided by engineer. + + + Chat with admins + chat toolbar + + + Chat with member No comment provided by engineer. @@ -1469,12 +1607,18 @@ 聊天 No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. + 每 20 分钟检查消息。 No comment provided by engineer. Check messages when allowed. + 在被允许时检查消息。 No comment provided by engineer. @@ -1532,6 +1676,16 @@ 清除对话吗? No comment provided by engineer. + + Clear group? + 清除群? + No comment provided by engineer. + + + Clear or delete group? + 清除还是删除群? + No comment provided by engineer. + Clear private notes? 清除私密笔记? @@ -1552,6 +1706,11 @@ 颜色模式 No comment provided by engineer. + + Community guidelines violation + 违反社区指导方针 + report reason + Compare file 对比文件 @@ -1569,38 +1728,37 @@ Conditions accepted on: %@. + 已于 %@ 接受条款。 No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + 已接受运营方 **%@** 的条款。 No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + 已经接受下列运营方的条款:**%@**。 No comment provided by engineer. Conditions of use - No comment provided by engineer. - - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. + 使用条款 No comment provided by engineer. Conditions will be accepted for the operator(s): **%@**. + 将接受下列运营方的条款:**%@**。 No comment provided by engineer. Conditions will be accepted on: %@. + 将于 %@ 接受条款。 No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + 将在 %@ 自动接受启用的运营方的条款。 No comment provided by engineer. @@ -1608,6 +1766,11 @@ 配置 ICE 服务器 No comment provided by engineer. + + Configure server operators + 配置服务器运营方 + No comment provided by engineer. + Confirm 确认 @@ -1658,6 +1821,11 @@ 确认上传 No comment provided by engineer. + + Confirmed + 已确定 + token status text + Connect 连接 @@ -1777,6 +1945,11 @@ This is your own one-time link! 连接和服务器状态。 No comment provided by engineer. + + Connection blocked + 连接被阻止 + No comment provided by engineer. + Connection error 连接错误 @@ -1787,6 +1960,17 @@ This is your own one-time link! 连接错误(AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + 连接被运营方 %@ 阻止 + No comment provided by engineer. + + + Connection not ready. + 连接未就绪。 + No comment provided by engineer. + Connection notifications 连接通知 @@ -1797,8 +1981,14 @@ This is your own one-time link! 已发送连接请求! No comment provided by engineer. + + Connection requires encryption renegotiation. + 连接需要加密重协商。 + No comment provided by engineer. + Connection security + 连接安全性 No comment provided by engineer. @@ -1876,6 +2066,11 @@ This is your own one-time link! 联系人可以将信息标记为删除;您将可以查看这些信息。 No comment provided by engineer. + + Content violates conditions of use + 内容违反使用条款 + blocking reason + Continue 继续 @@ -1903,6 +2098,7 @@ This is your own one-time link! Corner + 拐角 No comment provided by engineer. @@ -1917,6 +2113,7 @@ This is your own one-time link! Create 1-time link + 创建一次性链接 No comment provided by engineer. @@ -1949,6 +2146,11 @@ This is your own one-time link! 创建链接 No comment provided by engineer. + + Create list + 创建列表 + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻 @@ -2006,6 +2208,7 @@ This is your own one-time link! Current conditions text couldn't be loaded, you can review conditions via this link: + 无法加载当前条款文本,你可以通过此链接审阅条款: No comment provided by engineer. @@ -2030,6 +2233,7 @@ This is your own one-time link! Customizable message shape. + 可自定义消息形状。 No comment provided by engineer. @@ -2164,8 +2368,7 @@ This is your own one-time link! Delete 删除 alert action - chat item action - swipe action +swipe action Delete %lld messages of members? @@ -2204,6 +2407,12 @@ This is your own one-time link! Delete chat + 删除聊天 + No comment provided by engineer. + + + Delete chat messages from your device. + 从你的设备删除聊天消息。 No comment provided by engineer. @@ -2216,8 +2425,13 @@ This is your own one-time link! 删除聊天资料? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? + 删除聊天? No comment provided by engineer. @@ -2295,6 +2509,11 @@ This is your own one-time link! 删除链接? No comment provided by engineer. + + Delete list? + 删除列表? + alert title + Delete member message? 删除成员消息? @@ -2308,7 +2527,7 @@ This is your own one-time link! Delete messages 删除消息 - No comment provided by engineer. + alert button Delete messages after @@ -2327,6 +2546,7 @@ This is your own one-time link! Delete or moderate up to 200 messages. + 允许自行删除或管理员移除最多200条消息。 No comment provided by engineer. @@ -2344,6 +2564,11 @@ This is your own one-time link! 删除队列 server test step + + Delete report + 删除举报 + No comment provided by engineer. + Delete up to 20 messages at once. 一次最多删除 20 条信息。 @@ -2381,6 +2606,7 @@ This is your own one-time link! Delivered even when Apple drops them. + 已送达,即使苹果已将其删除。 No comment provided by engineer. @@ -2465,7 +2691,7 @@ This is your own one-time link! Device authentication is disabled. Turning off SimpleX Lock. - 设备验证被禁用。关闭 SimpleX 锁定。 + 设备验证已禁用。 SimpleX 已解锁。 No comment provided by engineer. @@ -2485,11 +2711,12 @@ This is your own one-time link! Direct messages between members are prohibited in this chat. + 此群禁止成员间私信。 No comment provided by engineer. Direct messages between members are prohibited. - 此群中禁止成员之间私信。 + 此群禁止成员间私信。 No comment provided by engineer. @@ -2502,6 +2729,16 @@ This is your own one-time link! 禁用 SimpleX 锁定 authentication reason + + Disable automatic message deletion? + 禁用消息自动销毁? + alert title + + + Disable delete messages + 停用消息删除 + alert button + Disable for all 全部禁用 @@ -2589,6 +2826,12 @@ This is your own one-time link! Do not use credentials with proxy. + 代理不使用身份验证凭据。 + No comment provided by engineer. + + + Documents: + 文档: No comment provided by engineer. @@ -2601,9 +2844,19 @@ This is your own one-time link! 不要启用 No comment provided by engineer. + + Don't miss important messages. + 不错过重要消息。 + No comment provided by engineer. + Don't show again 不再显示 + alert action + + + Done + 完成 No comment provided by engineer. @@ -2615,7 +2868,7 @@ This is your own one-time link! Download 下载 alert button - chat item action +chat item action Download errors @@ -2634,6 +2887,7 @@ This is your own one-time link! Download files + 下载文件 alert action @@ -2668,6 +2922,7 @@ This is your own one-time link! E2E encrypted notifications. + 端到端加密的通知。 No comment provided by engineer. @@ -2690,8 +2945,9 @@ This is your own one-time link! 启用(保持覆盖) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. + 在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。 No comment provided by engineer. @@ -2707,7 +2963,7 @@ This is your own one-time link! Enable automatic message deletion? 启用自动删除消息? - No comment provided by engineer. + alert title Enable camera access @@ -2834,6 +3090,11 @@ This is your own one-time link! 加密重协商失败了。 No comment provided by engineer. + + Encryption renegotiation in progress. + 正进行加密重协商。 + No comment provided by engineer. + Enter Passcode 输入密码 @@ -2901,6 +3162,7 @@ This is your own one-time link! Error accepting conditions + 接受条款出错 alert title @@ -2908,6 +3170,10 @@ This is your own one-time link! 接受联系人请求错误 No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) 添加成员错误 @@ -2915,6 +3181,7 @@ This is your own one-time link! Error adding server + 添加服务器出错 alert title @@ -2924,6 +3191,7 @@ This is your own one-time link! Error changing connection profile + 更改连接资料出错 No comment provided by engineer. @@ -2938,6 +3206,11 @@ This is your own one-time link! Error changing to incognito! + 切换至隐身聊天出错! + No comment provided by engineer. + + + Error checking token status No comment provided by engineer. @@ -2960,6 +3233,11 @@ This is your own one-time link! 创建群组链接错误 No comment provided by engineer. + + Error creating list + 创建列表出错 + alert title + Error creating member contact 创建成员联系人时出错 @@ -2975,6 +3253,11 @@ This is your own one-time link! 创建资料错误! No comment provided by engineer. + + Error creating report + 创建举报出错 + No comment provided by engineer. + Error decrypting file 解密文件时出错 @@ -2985,6 +3268,10 @@ This is your own one-time link! 删除聊天数据库错误 No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! 删除聊天错误! @@ -3057,10 +3344,12 @@ This is your own one-time link! Error loading servers + 加载服务器出错 alert title Error migrating settings + 迁移设置出错 No comment provided by engineer. @@ -3083,10 +3372,20 @@ This is your own one-time link! 重新连接服务器时出错 No comment provided by engineer. + + Error registering for notifications + 注册消息推送出错 + alert title + Error removing member 删除成员错误 - No comment provided by engineer. + alert title + + + Error reordering lists + 重排列表出错 + alert title Error resetting statistics @@ -3098,6 +3397,11 @@ This is your own one-time link! 保存 ICE 服务器错误 No comment provided by engineer. + + Error saving chat list + 保存聊天列表出错 + alert title + Error saving group profile 保存群组资料错误 @@ -3115,6 +3419,7 @@ This is your own one-time link! Error saving servers + 保存服务器出错 alert title @@ -3164,6 +3469,7 @@ This is your own one-time link! Error switching profile + 切换配置文件出错 No comment provided by engineer. @@ -3176,6 +3482,11 @@ This is your own one-time link! 同步连接错误 No comment provided by engineer. + + Error testing server connection + 检验服务器连接出错 + No comment provided by engineer. + Error updating group link 更新群组链接错误 @@ -3188,6 +3499,7 @@ This is your own one-time link! Error updating server + 更新服务器出错 alert title @@ -3218,7 +3530,9 @@ This is your own one-time link! Error: %@ 错误: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3237,6 +3551,7 @@ This is your own one-time link! Errors in servers configuration. + 服务器配置有错误。 servers error @@ -3254,6 +3569,11 @@ This is your own one-time link! 展开 chat item action + + Expired + 已过期 + token status text + Export database 导出数据库 @@ -3294,26 +3614,50 @@ This is your own one-time link! 快速且无需等待发件人在线! No comment provided by engineer. + + Faster deletion of groups. + 更快地删除群。 + No comment provided by engineer. + Faster joining and more reliable messages. 加入速度更快、信息更可靠。 No comment provided by engineer. + + Faster sending messages. + 更快发送消息。 + No comment provided by engineer. + Favorite 最喜欢 swipe action + + Favorites + 收藏 + No comment provided by engineer. + File error 文件错误 - No comment provided by engineer. + file error alert title File errors: %@ + 文件错误: +%@ alert message + + File is blocked by server operator: +%@. + 文件被服务器运营方阻止: +%@。 + file error text + File not found - most likely file was deleted or cancelled. 找不到文件 - 很可能文件已被删除或取消。 @@ -3439,8 +3783,14 @@ This is your own one-time link! 修复群组成员不支持的问题 No comment provided by engineer. + + For all moderators + 所有 moderators + No comment provided by engineer. + For chat profile %@: + 为聊天资料 %@: servers error @@ -3450,14 +3800,22 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + 比如,如果你通过 SimpleX 服务器收到消息,应用会通过 Flux 服务器传送它们。 + No comment provided by engineer. + + + For me + 仅自己 No comment provided by engineer. For private routing + 用于私密路由 No comment provided by engineer. For social media + 用于社交媒体 No comment provided by engineer. @@ -3467,6 +3825,7 @@ This is your own one-time link! Forward %d message(s)? + 转发 %d 条消息? alert title @@ -3476,14 +3835,17 @@ This is your own one-time link! Forward messages + 已转发的消息 alert action Forward messages without files? + 仅转发消息不转发文件? alert message Forward up to 20 messages at once. + 一次转发最多20条消息。 No comment provided by engineer. @@ -3498,6 +3860,7 @@ This is your own one-time link! Forwarding %lld messages + 正在转发 %lld 条消息 No comment provided by engineer. @@ -3569,6 +3932,11 @@ Error: %2$@ GIF 和贴纸 No comment provided by engineer. + + Get notified when mentioned. + 被提及时收到通知。 + No comment provided by engineer. + Good afternoon! 下午好! @@ -3674,11 +4042,21 @@ Error: %2$@ 将为您删除群组——此操作无法撤消! No comment provided by engineer. + + Groups + + No comment provided by engineer. + Help 帮助 No comment provided by engineer. + + Help admins moderating their groups. + 帮助管理员管理群组。 + No comment provided by engineer. + Hidden 隐藏 @@ -3731,12 +4109,19 @@ Error: %2$@ How it affects privacy + 它如何影响隐私 No comment provided by engineer. How it helps privacy + 它如何帮助隐私 No comment provided by engineer. + + How it works + 工作原理 + alert button + How to 如何 @@ -3764,6 +4149,7 @@ Error: %2$@ IP address + IP 地址 No comment provided by engineer. @@ -3844,6 +4230,8 @@ Error: %2$@ Improved delivery, reduced traffic usage. More improvements are coming soon! + 改善传送,降低流量使用。 +更多改进即将推出! No comment provided by engineer. @@ -3876,6 +4264,16 @@ More improvements are coming soon! 通话声音 No comment provided by engineer. + + Inappropriate content + 不当内容 + report reason + + + Inappropriate profile + 不当个人资料 + report reason + Incognito 隐身聊天 @@ -3968,6 +4366,31 @@ More improvements are coming soon! 界面颜色 No comment provided by engineer. + + Invalid + 无效 + token status text + + + Invalid (bad token) + Token 无效 + token status text + + + Invalid (expired) + 无效(已过期) + token status text + + + Invalid (unregistered) + 无效(未注册) + token status text + + + Invalid (wrong topic) + 无效(话题有误) + token status text + Invalid QR code 无效的二维码 @@ -4030,6 +4453,7 @@ More improvements are coming soon! Invite to chat + 邀请加入聊天 No comment provided by engineer. @@ -4192,10 +4616,12 @@ This is your link for group %@! Leave chat + 离开聊天 No comment provided by engineer. Leave chat? + 离开聊天? No comment provided by engineer. @@ -4238,6 +4664,21 @@ This is your link for group %@! 已链接桌面 No comment provided by engineer. + + List + 列表 + swipe action + + + List name and emoji should be different for all lists. + 所有列表的名称和表情符号都应不同。 + No comment provided by engineer. + + + List name... + 列表名… + No comment provided by engineer. + Live message! 实时消息! @@ -4323,13 +4764,23 @@ This is your link for group %@! 成员 No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive 成员不活跃 item status text + + Member reports + 成员举报 + chat feature + Member role will be changed to "%@". All chat members will be notified. + 将变更成员角色为“%@”。所有成员都会收到通知。 No comment provided by engineer. @@ -4344,6 +4795,7 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! + 将从聊天中删除成员 - 此操作无法撤销! No comment provided by engineer. @@ -4351,6 +4803,10 @@ This is your link for group %@! 成员将被移出群组——此操作无法撤消! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. 群组成员可以添加信息回应。 @@ -4361,6 +4817,11 @@ This is your link for group %@! 群组成员可以不可撤回地删除已发送的消息 No comment provided by engineer. + + Members can report messsages to moderators. + 成员可以向 moderators 举报消息。 + No comment provided by engineer. + Members can send SimpleX links. 群成员可发送 SimpleX 链接。 @@ -4386,6 +4847,11 @@ This is your link for group %@! 群组成员可以发送语音消息。 No comment provided by engineer. + + Mention members 👋 + 提及成员👋 + No comment provided by engineer. + Menus 菜单 @@ -4453,6 +4919,7 @@ This is your link for group %@! Message shape + 消息形状 No comment provided by engineer. @@ -4495,6 +4962,11 @@ This is your link for group %@! 将显示来自 %@ 的消息! No comment provided by engineer. + + Messages in this chat will never be deleted. + 此聊天中的消息永远不会被删除。 + alert message + Messages received 收到的消息 @@ -4507,6 +4979,7 @@ This is your link for group %@! Messages were deleted after you selected them. + 在你选中消息后这些消息已被删除。 alert message @@ -4594,6 +5067,11 @@ This is your link for group %@! 已被管理员移除于:%@ copied message info + + More + 更多 + swipe action + More improvements are coming soon! 更多改进即将推出! @@ -4606,6 +5084,7 @@ This is your link for group %@! More reliable notifications + 更可靠的通知 No comment provided by engineer. @@ -4621,7 +5100,12 @@ This is your link for group %@! Mute 静音 - swipe action + notification label action + + + Mute all + 全部静音 + notification label action Muted when inactive! @@ -4645,6 +5129,7 @@ This is your link for group %@! Network decentralization + 网络去中心化 No comment provided by engineer. @@ -4659,6 +5144,7 @@ This is your link for group %@! Network operator + 网络运营方 No comment provided by engineer. @@ -4671,6 +5157,11 @@ This is your link for group %@! 网络状态 No comment provided by engineer. + + New + + token status text + New Passcode 新密码 @@ -4678,10 +5169,12 @@ This is your link for group %@! New SOCKS credentials will be used every time you start the app. + 每次启动应用都会使用新的 SOCKS 凭据。 No comment provided by engineer. New SOCKS credentials will be used for each server. + 每个服务器都会使用新的 SOCKS 凭据。 No comment provided by engineer. @@ -4716,6 +5209,7 @@ This is your link for group %@! New events + 新事件 notification @@ -4733,6 +5227,10 @@ This is your link for group %@! 新成员角色 No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message 新消息 @@ -4745,6 +5243,7 @@ This is your link for group %@! New server + 新服务器 No comment provided by engineer. @@ -4757,6 +5256,25 @@ This is your link for group %@! 没有应用程序密码 Authentication unavailable + + No chats + 无聊天 + No comment provided by engineer. + + + No chats found + 找不到聊天 + No comment provided by engineer. + + + No chats in list %@ + 列表 %@ 中无聊天 + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + No contacts selected 未选择联系人 @@ -4804,10 +5322,17 @@ This is your link for group %@! No media & file servers. + 无媒体和文件服务器。 servers error + + No message + 无消息 + No comment provided by engineer. + No message servers. + 无消息服务器。 servers error @@ -4817,10 +5342,12 @@ This is your link for group %@! No permission to record speech + 无录音权限 No comment provided by engineer. No permission to record video + 无录像权限 No comment provided by engineer. @@ -4840,20 +5367,34 @@ This is your link for group %@! No servers for private message routing. + 无私密消息路由服务器。 servers error No servers to receive files. + 无文件接收服务器。 servers error No servers to receive messages. + 无消息接收服务器。 servers error No servers to send files. + 无文件发送服务器。 servers error + + No token! + 无 token! + alert title + + + No unread chats + 没有未读聊天 + No comment provided by engineer. + No user identifiers. 没有用户标识符。 @@ -4864,6 +5405,11 @@ This is your link for group %@! 不兼容! No comment provided by engineer. + + Notes + 附注 + No comment provided by engineer. + Nothing selected 未选中任何内容 @@ -4871,6 +5417,7 @@ This is your link for group %@! Nothing to forward! + 无可转发! alert title @@ -4883,10 +5430,21 @@ This is your link for group %@! 通知被禁用! No comment provided by engineer. + + Notifications error + 通知错误 + alert title + Notifications privacy + 通知隐私 No comment provided by engineer. + + Notifications status + 通知状态 + alert title + Now admins can: - delete members' messages. @@ -4909,7 +5467,8 @@ This is your link for group %@! Ok 好的 - alert button + alert action +alert button Old database @@ -4942,6 +5501,7 @@ Requires compatible VPN. Only chat owners can change preferences. + 仅聊天所有人可更改首选项。 No comment provided by engineer. @@ -4969,6 +5529,16 @@ Requires compatible VPN. 只有群主可以启用语音信息。 No comment provided by engineer. + + Only sender and moderators see it + 仅发送人和moderators能看到 + No comment provided by engineer. + + + Only you and moderators see it + 只有你和moderators能看到 + No comment provided by engineer. + Only you can add message reactions. 只有您可以添加消息回应。 @@ -5022,7 +5592,7 @@ Requires compatible VPN. Open 打开 - No comment provided by engineer. + alert action Open Settings @@ -5031,6 +5601,7 @@ Requires compatible VPN. Open changes + 打开更改 No comment provided by engineer. @@ -5045,6 +5616,7 @@ Requires compatible VPN. Open conditions + 打开条款 No comment provided by engineer. @@ -5052,6 +5624,10 @@ Requires compatible VPN. 打开群 No comment provided by engineer. + + Open link? + alert title + Open migration to another device 打开迁移到另一台设备 @@ -5064,14 +5640,17 @@ Requires compatible VPN. Operator + 运营方 No comment provided by engineer. Operator server + 运营方服务器 alert title Or import archive file + 或者导入或者导入压缩文件 No comment provided by engineer. @@ -5096,6 +5675,12 @@ Requires compatible VPN. Or to share privately + 或者私下分享 + No comment provided by engineer. + + + Organize chats into lists + 将聊天组织到列表 No comment provided by engineer. @@ -5145,6 +5730,7 @@ Requires compatible VPN. Password + 密码 No comment provided by engineer. @@ -5286,6 +5872,22 @@ Error: %@ 请安全地保存密码,如果您丢失了密码,您将无法更改它。 No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface 波兰语界面 @@ -5333,16 +5935,30 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + 隐私政策和使用条款。 + No comment provided by engineer. + Privacy redefined 重新定义隐私 No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + 服务器运营方无法访问私密聊天、群组和你的联系人。 + No comment provided by engineer. + Private filenames 私密文件名 No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing 私有消息路由 @@ -5418,6 +6034,10 @@ Error: %@ 禁止消息回应。 No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. 禁止发送 SimpleX 链接。 @@ -5674,11 +6294,23 @@ Enable in *Network & servers* settings. 减少电池使用量 No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject 拒绝 reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5690,6 +6322,10 @@ Enable in *Network & servers* settings. 拒绝联系人请求 No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. 中继服务器仅在必要时使用。其他人可能会观察到您的IP地址。 @@ -5774,6 +6410,50 @@ Enable in *Network & servers* settings. 回复 chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + Required 必须 @@ -5861,25 +6541,30 @@ Enable in *Network & servers* settings. Review conditions + 审阅条款 No comment provided by engineer. - - Review later - No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description Revoke - 撤销 + 吊销 No comment provided by engineer. Revoke file - 撤销文件 + 吊销文件 cancel file action Revoke file? - 撤销文件? + 吊销文件? No comment provided by engineer. @@ -5889,7 +6574,7 @@ Enable in *Network & servers* settings. Run chat - 运行聊天程序 + 运行聊天 No comment provided by engineer. @@ -5915,13 +6600,17 @@ Enable in *Network & servers* settings. Save 保存 alert button - chat item action +chat item action Save (and notify contacts) 保存(并通知联系人) alert button + + Save admission settings? + alert title + Save and notify contact 保存并通知联系人 @@ -5947,6 +6636,11 @@ Enable in *Network & servers* settings. 保存群组资料 No comment provided by engineer. + + Save list + 保存列表 + No comment provided by engineer. + Save passphrase and open chat 保存密码并打开聊天 @@ -5984,6 +6678,7 @@ Enable in *Network & servers* settings. Save your profile? + 保存您的个人资料? alert title @@ -6008,6 +6703,7 @@ Enable in *Network & servers* settings. Saving %lld messages + 正在保存 %lld 条消息 No comment provided by engineer. @@ -6184,6 +6880,10 @@ Enable in *Network & servers* settings. 发送通知 No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas 发送问题和想法 @@ -6394,6 +7094,10 @@ Enable in *Network & servers* settings. 设定1天 No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… 设置联系人姓名…… @@ -6414,6 +7118,14 @@ Enable in *Network & servers* settings. 设置它以代替系统身份验证。 No comment provided by engineer. + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + Set passcode 设置密码 @@ -6457,7 +7169,7 @@ Enable in *Network & servers* settings. Share 分享 alert action - chat item action +chat item action Share 1-time link @@ -6515,6 +7227,10 @@ Enable in *Network & servers* settings. 与联系人分享 No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code 显示二维码 @@ -6572,6 +7288,7 @@ Enable in *Network & servers* settings. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat 与 Flux 达成了协议,将由 Flux 控制的服务器纳入 SimpleX 应用。 No comment provided by engineer. @@ -6606,12 +7323,19 @@ Enable in *Network & servers* settings. SimpleX address and 1-time links are safe to share via any messenger. + 可以通过任何消息应用安全分享 SimpleX 地址和一次性链接。 No comment provided by engineer. SimpleX address or 1-time link? + SimpleX 地址或一次性链接? No comment provided by engineer. + + SimpleX channel link + SimpleX 频道链接 + simplex link type + SimpleX contact address SimpleX 联系地址 @@ -6619,12 +7343,12 @@ Enable in *Network & servers* settings. SimpleX encrypted message or connection event - SimpleX 加密消息或连接项目 + SimpleX 加密的消息或连接事件 notification SimpleX group link - SimpleX 群组链接 + SimpleX 群链接 simplex link type @@ -6649,6 +7373,7 @@ Enable in *Network & servers* settings. SimpleX protocols reviewed by Trail of Bits. + SimpleX 协议由 Trail of Bits 审阅。 No comment provided by engineer. @@ -6683,6 +7408,7 @@ Enable in *Network & servers* settings. Some app settings were not migrated. + 部分应用设置未被迁移。 No comment provided by engineer. @@ -6710,6 +7436,11 @@ Enable in *Network & servers* settings. 某人 notification title + + Spam + blocking reason +report reason + Square, circle, or anything in between. 方形、圆形、或两者之间的任意形状. @@ -6795,6 +7526,10 @@ Enable in *Network & servers* settings. 正在停止聊天 No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong 加粗 @@ -6853,6 +7588,10 @@ Enable in *Network & servers* settings. TCP 连接超时 No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6919,13 +7658,17 @@ Enable in *Network & servers* settings. Temporary file error 临时文件错误 - No comment provided by engineer. + file error alert title Test failed at step %@. 在步骤 %@ 上测试失败。 server test failure + + Test notifications + No comment provided by engineer. + Test server 测试服务器 @@ -7046,19 +7789,10 @@ It can happen because of some bug or when the connection is compromised.旧数据库在迁移过程中没有被移除,可以删除。 No comment provided by engineer. - - The profile is only shared with your contacts. - 该资料仅与您的联系人共享。 - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -7120,6 +7854,10 @@ It can happen because of some bug or when the connection is compromised.此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。 No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. 此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。 @@ -7165,11 +7903,19 @@ It can happen because of some bug or when the connection is compromised.这是你自己的一次性链接! No comment provided by engineer. + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + This link was used with another mobile device, please create a new link on the desktop. 此链接已在其他移动设备上使用,请在桌面上创建新链接。 No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. 此设置适用于您当前聊天资料 **%@** 中的消息。 @@ -7276,6 +8022,10 @@ You will be prompted to complete authentication before this feature is enabled.< 在连接时切换隐身模式。 No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity 工具栏不透明度 @@ -7440,13 +8190,17 @@ To connect, please ask your contact to create another connection link and check Unmute 取消静音 - swipe action + notification label action Unread 未读 swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. 给新成员发送了最多 100 条历史消息。 @@ -7472,6 +8226,10 @@ To connect, please ask your contact to create another connection link and check 更新设置? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. 更新设置会将客户端重新连接到所有服务器。 @@ -7530,6 +8288,14 @@ To connect, please ask your contact to create another connection link and check 使用 SimpleX Chat 服务器? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat 使用聊天 @@ -7592,6 +8358,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. 通话时使用本应用. @@ -7602,6 +8372,10 @@ To connect, please ask your contact to create another connection link and check 用一只手使用应用程序。 No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection 用户选择 @@ -7969,10 +8743,6 @@ Repeat join request? 您可以在外观设置中更改它。 No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8061,6 +8831,10 @@ Repeat join request? 您可以在连接详情中再次查看邀请链接。 alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! 您无法发送消息! @@ -8143,6 +8917,10 @@ Repeat connection request? 您发送了群组邀请 No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! 您将在组主设备上线时连接到该群组,请稍等或稍后再检查! @@ -8207,11 +8985,6 @@ Repeat connection request? 您的 ICE 服务器 No comment provided by engineer. - - Your SMP servers - 您的 SMP 服务器 - No comment provided by engineer. - Your SimpleX address 您的 SimpleX 地址 @@ -8294,6 +9067,11 @@ Repeat connection request? 您的个人资料 **%@** 将被共享。 No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + 该资料仅与您的联系人共享。 + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. 您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。 @@ -8303,11 +9081,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - 您的资料、联系人和发送的消息存储在您的设备上。 - No comment provided by engineer. - Your random profile 您的随机资料 @@ -8357,6 +9130,10 @@ Repeat connection request? 上面,然后选择: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call 已接受通话 @@ -8366,6 +9143,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin 管理员 @@ -8386,6 +9167,10 @@ Repeat connection request? 同意加密… chat item text + + all + member criteria value + all members 所有成员 @@ -8401,6 +9186,10 @@ Repeat connection request? 和 %lld 其他事件 No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts 尝试 @@ -8439,7 +9228,8 @@ Repeat connection request? blocked by admin 由管理员封禁 - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8466,6 +9256,10 @@ Repeat connection request? 呼叫中…… call status + + can't send messages + No comment provided by engineer. + cancelled %@ 已取消 %@ @@ -8571,6 +9365,14 @@ Repeat connection request? 联系人 %1$@ 已更改为 %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption 联系人具有端到端加密 @@ -8581,6 +9383,10 @@ Repeat connection request? 联系人没有端到端加密 No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator 创建者 @@ -8609,7 +9415,8 @@ Repeat connection request? default (%@) 默认 (%@) - pref value + delete after time +pref value default (no) @@ -8658,7 +9465,7 @@ Repeat connection request? duplicates - 复本 + 副本 No comment provided by engineer. @@ -8736,20 +9543,11 @@ Repeat connection request? 错误 No comment provided by engineer. - - event happened - 发生的事 - No comment provided by engineer. - expired 过期 No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded 已转发 @@ -8760,6 +9558,10 @@ Repeat connection request? 群组已删除 No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated 群组资料已更新 @@ -8885,6 +9687,10 @@ Repeat connection request? 已连接 rcv group event chat item + + member has old version + No comment provided by engineer. + message 消息 @@ -8915,20 +9721,19 @@ Repeat connection request? 由 %@ 审核 marked deleted chat item preview text + + moderator + member role + months time unit - - mute - 静音 - No comment provided by engineer. - never 从不 - No comment provided by engineer. + delete after time new message @@ -8950,6 +9755,10 @@ Repeat connection request? 无文本 copied message info in history + + not synchronized + No comment provided by engineer. + observer 观察者 @@ -8959,8 +9768,9 @@ Repeat connection request? off 关闭 enabled status - group pref value - time to disappear +group pref value +member criteria value +time to disappear offered %@ @@ -9002,6 +9812,18 @@ Repeat connection request? 点对点 No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + quantum resistant e2e encryption 抗量子端到端加密 @@ -9017,6 +9839,10 @@ Repeat connection request? 已受到确认…… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call 拒接来电 @@ -9037,6 +9863,10 @@ Repeat connection request? 删除了联系地址 profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture 删除了资料图片 @@ -9047,10 +9877,22 @@ Repeat connection request? 已将您移除 rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved 已保存 @@ -9150,11 +9992,6 @@ last received msg: %2$@ 未知状态 No comment provided by engineer. - - unmute - 取消静音 - No comment provided by engineer. - unprotected 未受保护 @@ -9245,6 +10082,10 @@ last received msg: %2$@ No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group 您被邀请加入群组 @@ -9319,7 +10160,7 @@ last received msg: %2$@
- +
@@ -9356,7 +10197,7 @@ last received msg: %2$@
- +
@@ -9378,13 +10219,17 @@ last received msg: %2$@
- +
%d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9397,15 +10242,11 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body -
- +
@@ -9427,7 +10268,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json index 6416a2d8fa..91977b0744 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "zh-Hans", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff index 93b9725131..3ea46ee364 100644 --- a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff @@ -109,7 +109,7 @@ %d skipped message(s) - %d錯過了訊息 + 錯過的 %d 則訊息 integrity error chat item @@ -124,17 +124,17 @@ %lld contact(s) selected - %lld 已選擇聯絡人(s) + 已選擇 %lld 個聯絡人 No comment provided by engineer. %lld file(s) with total size of %@ - %lld 檔案(s) 的總共大小為%@ + %lld 個檔案,總共大小 %@ No comment provided by engineer. %lld members - %lld 成員 + %lld 個成員 No comment provided by engineer. @@ -224,7 +224,7 @@ **Warning**: Instant push notifications require passphrase saved in Keychain. - **警告**:即時推送訊息通知需要數據庫的密碼儲存在資料庫中。 + **警告**:即時推送訊息通知需要將數據庫的密碼儲存在資料庫中。 No comment provided by engineer. @@ -2704,12 +2704,12 @@ We will be adding server redundancy to prevent lost messages. Send link previews - 傳送可以預覽的連結 + 傳送連結預覽 No comment provided by engineer. Send live message - 傳送實況的訊息 + 傳送實時訊息 No comment provided by engineer. @@ -2724,7 +2724,7 @@ We will be adding server redundancy to prevent lost messages. Send questions and ideas - 傳送問題和想法給開發者 + 給開發者提問題和想法 No comment provided by engineer. @@ -2774,7 +2774,7 @@ We will be adding server redundancy to prevent lost messages. Set 1 day - 設定為1天 + 設定為 1 天 No comment provided by engineer. @@ -3022,7 +3022,7 @@ We will be adding server redundancy to prevent lost messages. The connection you accepted will be cancelled! - 你所接受的連接將被取消! + 你接受的連接將被取消! No comment provided by engineer. @@ -3054,14 +3054,14 @@ We will be adding server redundancy to prevent lost messages. 舊的數據庫在遷移過程中沒有被移除,可以刪除。 No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. 你的個人檔案只會和你的聯絡人分享。 No comment provided by engineer. The sender will NOT be notified - 發送者不會接收到通知 + 發送者不會收到通知 No comment provided by engineer. @@ -3071,12 +3071,12 @@ We will be adding server redundancy to prevent lost messages. This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - 這操作不能還原 - 所有已經接收和傳送的檔案和媒體檔案將刪除。低解析度圖片將保留。 + 這操作不能還原 - 將刪除所有已經接收和傳送的檔案和媒體。將保留低解析度圖片。 No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - 這操作無法撤銷 - 早於所選擇的時間發送和接收的訊息將被刪除。這可能需要幾分鐘的時間。 + 這操作無法撤銷 - 早於所選時間的收發訊息將被刪除。可能需要幾分鐘。 No comment provided by engineer. @@ -3263,7 +3263,7 @@ To connect, please ask your contact to create another connection link and check Use for new connections - 用於新的連接 + 用於新的連線 No comment provided by engineer. @@ -3283,7 +3283,7 @@ To connect, please ask your contact to create another connection link and check Verify connection security - 驗證連接安全性 + 驗證連線安全性 No comment provided by engineer. @@ -4163,7 +4163,7 @@ SimpleX 伺服器並不會看到你的個人檔案。 via contact address link - 透過聯絡人的邀請連結連接 + 透過聯絡人的邀請連結連線 chat list item description @@ -4173,7 +4173,7 @@ SimpleX 伺服器並不會看到你的個人檔案。 via one-time link - 透過一次性連結連接 + 透過一次性連結連線 chat list item description @@ -4702,7 +4702,7 @@ Available in v5.1 %u messages failed to decrypt. - %u 訊息解密失敗。 + %u 則訊息解密失敗。 No comment provided by engineer. @@ -5152,7 +5152,7 @@ Available in v5.1 Tap to activate profile. - 點擊以激活配置檔案。 + 點擊以激活設定檔。 No comment provided by engineer. @@ -6034,7 +6034,7 @@ It can happen because of some bug or when the connection is compromised. %lld messages marked deleted - %lld 條訊息已刪除 + %lld 則訊息已標記為刪除 Already connecting! @@ -6046,7 +6046,7 @@ It can happen because of some bug or when the connection is compromised. (new) - (新) + (新) %@, %@ and %lld other members connected @@ -6112,6 +6112,374 @@ It can happen because of some bug or when the connection is compromised.Background 後台 + + SimpleX links not allowed + 不允許 SimpleX 連結 + + + Voice messages not allowed + 不允許語音訊息 + + + The text you pasted is not a SimpleX link. + 您貼在這裡的連結不是 SimpleX 連結。 + + + %d file(s) were deleted. + 已刪除 %d 個檔案。 + + + Reset to app theme + 重設至應用程式主題 + + + Retry + 重試 + + + The uploaded database archive will be permanently removed from the servers. + 上傳的資料庫存檔將從伺服器永久移除。 + + + Shape profile images + 塑造個人資料圖片 + + + **Scan / Paste link**: to connect via a link you received. + **掃描/貼上連結**:以透過您收到的連結連線。 + + + Reports + 舉報 + + + Use SOCKS proxy + 使用 SOCKS 代理 + + + Reset all statistics + 重設所有統計數據 + + + SOCKS proxy + SOCKS 代理 + + + Send message to enable calls. + 發送訊息以啟用通話功能。 + + + Send direct message to connect + 直接發送訊息以連結 + + + Scale + 顯示比例 + + + Sent via proxy + 通過代理發送 + + + Servers info + 伺服器訊息 + + + Set message expiration in chats. + 設定聊天中訊息期限。 + + + Share SimpleX address on social media. + 在社交媒體上分享 SimpleX 聯絡地址。 + + + Storage + 存儲 + + + Starting from %@. + 開始於 %@。 + + + The second tick we missed! ✅ + 我們錯過的第二個勾選! ✅ + + + Themes + 主題 + + + %d file(s) failed to download. + %d 個檔案下載失敗。 + + + Session code + 會話代碼 + + + Servers statistics will be reset - this cannot be undone! + 伺服器統計資料將被重設 - 此操作無法撤銷! + + + **Create 1-time link**: to create and share a new invitation link. + **建立一次性連結**:建立並分享新邀請連結。 + + + Set default theme + 設定缺省主題 + + + %lld group events + %lld 個群組事件 + + + Reset all statistics? + 重設所有統計數據? + + + %@ server + %@ 伺服器 + + + %d file(s) were not downloaded. + %d 個檔案未下載。 + + + %d messages not forwarded + %d 則訊息未轉發 + + + Test notifications + 测试通知 + + + (this device v%@) + (此設備 v%@) + + + Settings were changed. + 設定已更改。 + + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + 這操作不能撤銷 - 此聊天中早於所選訊息的收發訊息將被刪除。 + + + Subscription errors + 訂閱錯誤 + + + Report + 舉報 + + + Send messages directly when IP address is protected and your or destination server does not support private routing. + 當 IP 位址受保護且您或目的地伺服器不支援私人路由時,直接傳送訊息。 + + + Reset to user theme + 重設為使用者主題 + + + Use short links (BETA) + 使用短連結(Beta) + + + Up to 100 last messages are sent to new members. + 最多 100 則最後的訊息會傳送至新成員。 + + + %d seconds(s) + %d 秒 + + + %d file(s) are still being downloaded. + 仍在下載 %d 個檔案。 + + + %lld messages blocked by admin + %lld 則訊息被管理員封鎖 + + + Report: %@ + 舉報:%@ + + + Review conditions + 檢視使用條款 + + + Search or paste SimpleX link + 搜尋或貼上 SimpleX 連結 + + + Sent directly + 已直接發送 + + + SimpleX links are prohibited. + 這群組禁止 SimpleX 連結。 + + + Uploaded files + 已上傳的檔案 + + + Use %@ + 使用 %@ + + + Upload errors + 上傳錯誤 + + + Use servers + 使用伺服器 + + + security code changed + 安全碼已變更 + + + These settings are for your current profile **%@**. + 這些設定是針對您目前的設定檔 **%@**。 + + + They can be overridden in contact and group settings. + 您可在連絡人和群組設定中覆寫它們。 + + + %1$@, %2$@ + %1$@, %2$@ + + + Verify connections + 驗證連線 + + + Verify connection + 驗證連線 + + + Verify passphrase + 驗證密碼 + + + Verify code with desktop + 使用桌上電腦驗證代碼 + + + Save list + 儲存列表 + + + Saving %lld messages + 正在儲存 %lld 則訊息 + + + search + 搜尋 + + + requested to connect + 已請求連結 + + + saved + 已儲存 + + + video + 視訊 + + + Tap to Connect + 點擊以連結 + + + Unsupported connection link + 未受支持的連線連結 + + + Saved from + 儲存自 + + + Saved + 已儲存 + + + Scan / Paste link + 掃描/貼上連結 + + + SimpleX + SimpleX + + + Use the app while in the call. + 在通話時使用此應用程式。 + + + v%@ + v%@ + + + Save your profile? + 儲存設定檔? + + + Use for messages + 用於訊息 + + + Uploading archive + 正在上傳檔案庫 + + + Unlink + 從桌上電腦解除連結 + + + %lld messages blocked + 已封鎖 %d 則訊息 + + + The same conditions will apply to operator **%@**. + 相同條件也適用於 **%@** 操作員。 + + + These conditions will also apply for: **%@**. + 這些條件也適用於:**%@**。 + + + Upload failed + 上傳失敗 + + + Use the app with one hand. + 單手使用此應用程式。 + + + Safely receive files + 安全地接收檔案 + + + Saved message + 已儲存的訊息 + + + Use from desktop + 在桌上電腦上使用 + + + Via secure quantum resistant protocol. + 使用量子安全的協定。 + + + Uploaded + 已上傳 +
diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift new file mode 100644 index 0000000000..35a838fff9 --- /dev/null +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -0,0 +1,127 @@ +// +// APITypes.swift +// SimpleX +// +// Created by EP on 01/05/2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SimpleXChat + +enum NSEChatCommand: ChatCmdProtocol { + case showActiveUser + case startChat(mainApp: Bool, enableSndFiles: Bool) + case apiActivateChat(restoreChat: Bool) + case apiSuspendChat(timeoutMicroseconds: Int) + case apiSetNetworkConfig(networkConfig: NetCfg) + case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) + case apiSetEncryptLocalFiles(enable: Bool) + case apiGetNtfConns(nonce: String, encNtfInfo: String) + case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) + case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) + case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) + + var cmdString: String { + switch self { + case .showActiveUser: return "/u" + case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" + case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" + case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): + return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" + case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" + case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" + case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))" + } + } + + private func onOffParam(_ param: String, _ b: Bool?) -> String { + if let b = b { + " \(param)=\(onOff(b))" + } else { + "" + } + } +} + +enum NSEChatResponse: Decodable, ChatAPIResult { + case activeUser(user: User) + case chatStarted + case chatRunning + case rcvFileAccepted(user: UserRef, chatItem: AChatItem) + case ntfConns(ntfConns: [NtfConn]) + case connNtfMessages(receivedMsgs: [RcvNtfMsgInfo]) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) + case cmdOk(user_: UserRef?) + + var responseType: String { + switch self { + case .activeUser: "activeUser" + case .chatStarted: "chatStarted" + case .chatRunning: "chatRunning" + case .rcvFileAccepted: "rcvFileAccepted" + case .ntfConns: "ntfConns" + case .connNtfMessages: "connNtfMessages" + case .ntfMessage: "ntfMessage" + case .cmdOk: "cmdOk" + } + } + + var details: String { + switch self { + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .ntfConns(ntfConns): return String(describing: ntfConns) + case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") + case .cmdOk: return noDetails + } + } +} + +enum NSEChatEvent: Decodable, ChatAPIResult { + case chatSuspended + case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) + case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case callInvitation(callInvitation: RcvCallInvitation) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) + + var responseType: String { + switch self { + case .chatSuspended: "chatSuspended" + case .contactConnected: "contactConnected" + case .receivedContactRequest: "receivedContactRequest" + case .newChatItems: "newChatItems" + case .rcvFileSndCancelled: "rcvFileSndCancelled" + case .sndFileComplete: "sndFileComplete" + case .sndFileRcvCancelled: "sndFileRcvCancelled" + case .callInvitation: "callInvitation" + case .ntfMessage: "ntfMessage" + } + } + + var details: String { + switch self { + case .chatSuspended: return noDetails + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .callInvitation(inv): return String(describing: inv) + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") + } + } +} diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index ce80adf38f..176da2481e 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -22,12 +22,6 @@ let nseSuspendSchedule: SuspendSchedule = (2, 4) let fastNSESuspendSchedule: SuspendSchedule = (1, 1) -enum NSENotification { - case nse(UNMutableNotificationContent) - case callkit(RcvCallInvitation) - case empty -} - public enum NSENotificationData { case connectionEvent(_ user: User, _ connEntity: ConnectionEntity) case contactConnected(_ user: any UserLike, _ contact: Contact) @@ -37,6 +31,7 @@ public enum NSENotificationData { case msgInfo(NtfMsgAckInfo) case noNtf + @inline(__always) var callInvitation: RcvCallInvitation? { switch self { case let .callInvitation(invitation): invitation @@ -56,8 +51,9 @@ public enum NSENotificationData { } } + @inline(__always) var notificationEvent: NSENotificationData? { - return switch self { + switch self { case .connectionEvent: self case .contactConnected: self case .contactRequest: self @@ -68,9 +64,10 @@ public enum NSENotificationData { } } - var newMsgData: (any UserLike, ChatInfo)? { - return switch self { - case let .messageReceived(user, cInfo, _): (user, cInfo) + @inline(__always) + var newMsgNtf: NSENotificationData? { + switch self { + case .messageReceived: self default: nil } } @@ -81,20 +78,25 @@ public enum NSENotificationData { // or when background notification is received. class NSEThreads { static let shared = NSEThreads() - static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") + private let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") private var allThreads: Set = [] - var activeThreads: [(UUID, NotificationService)] = [] - var droppedNotifications: [(ChatId, NSENotificationData)] = [] + private var activeThreads: [(threadId: UUID, nse: NotificationService)] = [] + private var droppedNotifications: [(entityId: ChatId, ntf: NSENotificationData)] = [] + @inline(__always) + private init() {} // only shared instance can be used + + @inline(__always) func newThread() -> UUID { - NSEThreads.queue.sync { + queue.sync { let (_, t) = allThreads.insert(UUID()) return t } } + @inline(__always) func startThread(_ t: UUID, _ service: NotificationService) { - NSEThreads.queue.sync { + queue.sync { if allThreads.contains(t) { activeThreads.append((t, service)) } else { @@ -103,24 +105,111 @@ class NSEThreads { } } + // atomically: + // - checks that passed NSE instance can start processing passed notification entity, + // - adds it to the passed NSE instance, + // - marks as started, if no other NSE instance is processing it. + // Making all these steps atomic prevents a race condition between threads when both will be added and none will be started + @inline(__always) + func startEntity(_ nse: NotificationService, _ ntfEntity: NotificationEntity) -> Bool { + queue.sync { + // checking that none of activeThreads with another NSE instance processes the same entity and is not ready + let canStart = !activeThreads.contains(where: { (tId, otherNSE) in + tId != nse.threadId + && otherNSE.notificationEntities.contains(where: { (id, otherEntity) in + id == ntfEntity.entityId + && otherEntity.expectedMsg != nil + }) + }) + // atomically add entity to passed NSE instance + let id = ntfEntity.entityId + nse.notificationEntities[id] = ntfEntity + if canStart { + // and set as started, so it cannot be chosen to start by another NSE entity in nextThread + nse.notificationEntities[id]?.startedProcessingNewMsgs = true + } + return canStart + } + } + + @inline(__always) + func addDroppedNtf(_ id: ChatId, _ ntf: NSENotificationData) { + queue.sync { droppedNotifications.append((id, ntf)) } + } + + // atomically remove and return first dropped notification for the passed entity + @inline(__always) + func takeDroppedNtf(_ ntfEntity: NotificationEntity) -> (entityId: ChatId, ntf: NSENotificationData)? { + queue.sync { + if droppedNotifications.isEmpty { + nil + } else if let i = droppedNotifications.firstIndex(where: { (id, _) in id == ntfEntity.entityId }) { + droppedNotifications.remove(at: i) + } else { + nil + } + } + } + + // passes notification for processing to NSE instance chosen by rcvEntityThread + @inline(__always) func processNotification(_ id: ChatId, _ ntf: NSENotificationData) async -> Void { - if let (_, nse) = rcvEntityThread(id), - nse.expectedMessages[id]?.shouldProcessNtf ?? false { - nse.processReceivedNtf(id, ntf, signalReady: true) + if let (nse, ntfEntity, expectedMsg) = rcvEntityThread(id, ntf) { + logger.debug("NotificationService processNotification \(id): found nse thread expecting message") + if nse.processReceivedNtf(ntfEntity, expectedMsg, ntf) { + nse.finalizeEntity(id) + } } } - private func rcvEntityThread(_ id: ChatId) -> (UUID, NotificationService)? { - NSEThreads.queue.sync { + // atomically: + // - chooses active NSE instance that is ready to process notifications and expects message for passed entity ID + // - returns all dependencies for processing (notification entity and expected message) + // - adds notification to droppedNotifications if no ready NSE instance is found for the entity + @inline(__always) + private func rcvEntityThread(_ id: ChatId, _ ntf: NSENotificationData) -> (NotificationService, NotificationEntity, NtfMsgInfo)? { + queue.sync { // this selects the earliest thread that: - // 1) has this connection in nse.expectedMessages - // 2) has not completed processing messages for this connection (not ready) - activeThreads.first(where: { (_, nse) in nse.expectedMessages[id]?.ready == false }) + // 1) has this connection entity in nse.notificationEntitites + // 2) has not completed processing messages for this connection entity (not ready) + let r = activeThreads.lazy.compactMap({ (_, nse) in + let ntfEntity = nse.notificationEntities[id] + return if let ntfEntity, let expectedMsg = ntfEntity.expectedMsg, ntfEntity.shouldProcessNtf { + (nse, ntfEntity, expectedMsg) + } else { + nil + } + }).first + if r == nil { droppedNotifications.append((id, ntf)) } + return r } } + // Atomically mark entity in the passed NSE instance as not expecting messages, + // and signal the next NSE instance with this entity to start its processing. + @inline(__always) + func signalNextThread(_ nse: NotificationService, _ id: ChatId) { + queue.sync { + nse.notificationEntities[id]?.expectedMsg = nil + nse.notificationEntities[id]?.shouldProcessNtf = false + let next = activeThreads.first(where: { (_, nseNext) in + if let ntfEntity = nseNext.notificationEntities[id] { + ntfEntity.expectedMsg != nil && !ntfEntity.startedProcessingNewMsgs + } else { + false + } + }) + if let (tNext, nseNext) = next { + if let t = nse.threadId { logger.debug("NotificationService thread \(t): signalNextThread: signal next thread \(tNext) for entity \(id)") } + nseNext.notificationEntities[id]?.startedProcessingNewMsgs = true + nseNext.notificationEntities[id]?.semaphore.signal() + } + } + } + + @inline(__always) func endThread(_ t: UUID) -> Bool { - NSEThreads.queue.sync { + queue.sync { let tActive: UUID? = if let index = activeThreads.firstIndex(where: { $0.0 == t }) { activeThreads.remove(at: index).0 } else { @@ -137,21 +226,53 @@ class NSEThreads { } } + @inline(__always) var noThreads: Bool { allThreads.isEmpty } } -struct ExpectedMessage { - var ntfConn: UserNtfConn - var receiveConnId: String? - var expectedMsgId: String? - var allowedGetNextAttempts: Int - var msgBestAttemptNtf: NSENotificationData? - var ready: Bool - var shouldProcessNtf: Bool - var startedProcessingNewMsgs: Bool - var semaphore: DispatchSemaphore +// NotificationEntity is a processing state for notifications from a single connection entity (message queue). +// Each NSE instance within NSE process can have more than one NotificationEntity. +// NotificationEntities of an NSE instance are processed concurrently, as messages arrive in any order. +// NotificationEntities for the same connection across multiple NSE instances (NSEThreads) are processed sequentially, so that the earliest NSE instance receives the earliest messages. +// The reason for this complexity is to process all required messages within allotted 30 seconds, +// accounting for the possibility that multiple notifications may be delivered concurrently. +struct NotificationEntity { + var ntfConn: NtfConn + var entityId: ChatId + + // expectedMsg == nil means that entity already has the best attempt to deliver, and no more messages are expected. + // It happens when: + // - the user is muted (set to nil in mkNotificationEntity) + // - apiGetNtfConns returns that there are no new messages (msgId in notification matches previously received), + // - messaging server fails to respond or replies that there are no messages (apiGetConnNtfMessages / getConnNtfMessage), + // - the message is received with the correct ID or timestamp (set to nil in signalNextThread). + var expectedMsg: NtfMsgInfo? + var allowedGetNextAttempts: Int = 3 + var msgBestAttemptNtf: NSENotificationData + + // startedProcessingNewMsgs determines that the entity stared processing events once it processed dropped notifications. + // It remains true when shouldProcessNtf is set to false, to prevent NSE from being chosen as the next for the entity. + // It is atomically set to true by startThead or by nextThread + var startedProcessingNewMsgs: Bool = false + + // shouldProcessNtf determines that NSE should process events for this entity, + // it is atomically set: + // - to true in processDroppedNotifications in case dropped notification is not chosen for delivery, and more messages are needed. + // - to false in nextThread + var shouldProcessNtf: Bool = false + + // this semaphone is used to wait for another NSE instance processing events for the same entity + var semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + + var connMsgReq: ConnMsgReq? { + if let expectedMsg { + ConnMsgReq(msgConnId: ntfConn.agentConnId, msgDbQueueId: ntfConn.agentDbQueueId, msgTs: expectedMsg.msgTs) + } else { + nil + } + } } // Notification service extension creates a new instance of the class and calls didReceive for each notification. @@ -161,36 +282,46 @@ struct ExpectedMessage { class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? // served as notification if no message attempts (msgBestAttemptNtf) could be produced - var serviceBestAttemptNtf: NSENotification? + var serviceBestAttemptNtf: UNMutableNotificationContent? var badgeCount: Int = 0 // thread is added to allThreads here - if thread did not start chat, // chat does not need to be suspended but NSE state still needs to be set to "suspended". var threadId: UUID? = NSEThreads.shared.newThread() - var expectedMessages: Dictionary = [:] // key is receiveEntityId + var notificationEntities: Dictionary = [:] // key is entityId var appSubscriber: AppSubscriber? var returnedSuspension = false override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") - let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } - setServiceBestAttemptNtf(ntf) + let receivedNtf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } + setServiceBestAttemptNtf(receivedNtf) self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() logger.debug("NotificationService: app is \(appState.rawValue)") switch appState { case .stopped: +// Use this block to debug notificaitons delivery in CLI, with "ejected" database and stopped chat +// if let nrData = ntfRequestData(request) { +// logger.debug("NotificationService get notification connections: /_ntf conns \(nrData.nonce) \(nrData.encNtfInfo)") +// contentHandler(receivedNtf) +// return; +// } setBadgeCount() - setServiceBestAttemptNtf(createAppStoppedNtf(badgeCount)) - deliverBestAttemptNtf() + contentHandler(createAppStoppedNtf(badgeCount)) case .suspended: - receiveNtfMessages(request, contentHandler) + setExpirationTimer() + receiveNtfMessages(request) case .suspending: + // while application is suspending, the current instance will be waiting + setExpirationTimer() Task { let state: AppState = await withCheckedContinuation { cont in + // this subscriber uses message delivery via NSFileCoordinator to communicate between the app and NSE appSubscriber = appStateSubscriber { s in if s == .suspended { appSuspension(s) } } + // this is a fallback timeout, in case message from the app does not arrive DispatchQueue.global().asyncAfter(deadline: .now() + Double(appSuspendTimeout) + 1) { logger.debug("NotificationService: appSuspension timeout") appSuspension(appStateGroupDefault.get()) @@ -206,119 +337,179 @@ class NotificationService: UNNotificationServiceExtension { } } logger.debug("NotificationService: app state is now \(state.rawValue)") - if state.inactive { - receiveNtfMessages(request, contentHandler) + if state.inactive && self.contentHandler != nil { + receiveNtfMessages(request) } else { - deliverBestAttemptNtf() + contentHandler(receivedNtf) } } - case .active: contentHandler(UNMutableNotificationContent()) - case .activating: contentHandler(UNMutableNotificationContent()) - case .bgRefresh: contentHandler(UNMutableNotificationContent()) + case .active: contentHandler(receivedNtf) + case .activating: contentHandler(receivedNtf) + case .bgRefresh: contentHandler(receivedNtf) } } - func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { + // This timer compensates for the scenarios when serviceExtensionTimeWillExpire does not fire at all. + // It is not clear why in some cases it does not fire, possibly it is a bug, + // or it depends on what the current thread is doing at the moment. + // If notification is not delivered and not cancelled, no further notifications will be processed. + @inline(__always) + private func setExpirationTimer() -> Void { + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { + self.deliverBestAttemptNtf(urgent: true) + } + } + + @inline(__always) + private func ntfRequestData(_ request: UNNotificationRequest) -> (nonce: String, encNtfInfo: String)? { + if let ntfData = request.content.userInfo["notificationData"] as? [AnyHashable : Any], + let nonce = ntfData["nonce"] as? String, + let encNtfInfo = ntfData["message"] as? String { + (nonce, encNtfInfo) + } else { + nil + } + } + + // This function triggers notification message delivery for connection entities referenced in the notification. + // Notification may reference multiple connection entities (message queues) in order to compensate for Apple servers + // only delivering the latest notification, so it allows receiving messages from up to 6 contacts and groups from a + // single notification. This aggregation is handled by a notification server and is delivered via APNS servers in + // e2e encrypted envelope, and the app core prevents duplicate processing by keeping track of the last processed message. + + // The process steps: + // 0. apiGetConnNtfMessages or getConnNtfMessage get messages from the server for passed connection entities. + // We don't know in advance which chat events will be delivered from app core for a given notification, + // it may be a message, but it can also be contact request, various protocol confirmations, calls, etc., + // this function only returns metadata for the expected chat events. + // This metadata is correlated with .ntfMessage core event / .msgInfo notification marker - + // this marker allows determining when some message completed processing. + // 1. receiveMessages: singleton loop receiving events from core. + // 2. receivedMsgNtf: maps core events to notification events. + // 3. NSEThreads.shared.processNotification: chooses which notification service instance in the current process should process notification. + // While most of the time we observe that notifications are delivered sequentially, nothing in the documentation confirms it is sequential, + // and from various sources it follows that each instance executes in its own thread, so concurrency is expected. + // 4. processReceivedNtf: one of the instances of NSE processes notification event, deciding whether to request further messages + // for a given connection entity (via getConnNtfMessage) or that the correct message was received and notification can be delivered (deliverBestAttemptNtf). + // It is based on .msgInfo markers that indicate that message with a given timestamp was processed. + // 5. deliverBestAttemptNtf: is called multiple times, once each connection receives enough messages (based on .msgInfo marker). + // If further messages are expected, this function does nothing (unless it is called with urgent flag from timeout/expiration handlers). + func receiveNtfMessages(_ request: UNNotificationRequest) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { deliverBestAttemptNtf() return } - let userInfo = request.content.userInfo - if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], - let nonce = ntfData["nonce"] as? String, - let encNtfInfo = ntfData["message"] as? String, - // check it here again + if let nrData = ntfRequestData(request), + // Check that the app is still inactive before starting the core. appStateGroupDefault.get().inactive { // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended - if let t = threadId { NSEThreads.shared.startThread(t, self) } + guard let t = threadId else { return } + NSEThreads.shared.startThread(t, self) let dbStatus = startChat() + // If database is opened successfully, get the list of connection entities (group members, contacts) + // that are referenced in the encrypted notification metadata. if case .ok = dbStatus, - let ntfConns = apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo) { + let ntfConns = apiGetNtfConns(nonce: nrData.nonce, encNtfInfo: nrData.encNtfInfo) { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns count = \(ntfConns.count)") + // uncomment localDisplayName in ConnectionEntity + // logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns \(String(describing: ntfConns.map { $0.connEntity.localDisplayName }))") - for ntfConn in ntfConns { - addExpectedMessage(ntfConn: ntfConn) - } + // Prepare expected messages - they will be delivered to the reception loop in this chain: + // They are atomically added to the instance notificationEntities inside msgReqs loop, to avoid any race conditions. + let ntfEntities = ntfConns.compactMap(mkNotificationEntity) - let connIdsToGet = expectedMessages.compactMap { (id, _) in - let started = NSEThreads.queue.sync { - let canStart = checkCanStart(id) - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: can start: \(canStart)") } - if canStart { - processDroppedNotifications(id) - expectedMessages[id]?.startedProcessingNewMsgs = true - expectedMessages[id]?.shouldProcessNtf = true - } - return canStart - } - if started { - return expectedMessages[id]?.receiveConnId - } else { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") } - expectedMessages[id]?.semaphore.wait() - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) proceeding after semaphore") } - Task { - NSEThreads.queue.sync { - processDroppedNotifications(id) - expectedMessages[id]?.startedProcessingNewMsgs = true - expectedMessages[id]?.shouldProcessNtf = true + // collect notification message requests for all connection entities + let msgReqs: [(chatId: String, connMsgReq: ConnMsgReq)] = ntfEntities.compactMap { ntfEntity -> (chatId: String, connMsgReq: ConnMsgReq)? in + // No need to request messages for connection entities that are "ready", + // e.g. for muted users or when the message is not expected based on notification. + let id = ntfEntity.entityId + if let expectedMsg = ntfEntity.expectedMsg { + if NSEThreads.shared.startEntity(self, ntfEntity) { // atomically checks and adds ntfEntity to NSE + // process any notifications "postponed" by the previous instance + let completed = processDroppedNotifications(ntfEntity, expectedMsg) + return if !completed, let connMsgReq = notificationEntities[id]?.connMsgReq { + (id, connMsgReq) + } else { + nil } - if let connId = expectedMessages[id]?.receiveConnId { - let _ = getConnNtfMessage(connId: connId) + } else { + // wait for another instance processing the same connection entity + logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") + // this semaphore will be released by signalNextThread function, that looks up the instance + // waiting for the connection entity via activeThreads in NSEThreads + notificationEntities[id]?.semaphore.wait() + logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) proceeding after semaphore") + Task { + // process any notifications "postponed" by the previous instance + let completed = processDroppedNotifications(ntfEntity, expectedMsg) + // Request messages from the server for this connection entity. + // It triggers event delivery to receiveMessages loop (see above). + if !completed, let connMsgReq = notificationEntities[id]?.connMsgReq, + let rcvMsg = getConnNtfMessage(connMsgReq: connMsgReq), + rcvMsg.noMsg { + // if server returns error or "no message", deliver what we have for this connection entity. + finalizeEntity(id) // also releases any waiting threads for this entity + } } + return nil } + } else { // no expected message + notificationEntities[id] = ntfEntity return nil } } - if !connIdsToGet.isEmpty { - if let r = apiGetConnNtfMessages(connIds: connIdsToGet) { - logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count)") + // Request messages for all connection entities that were not used by other instances. + // It triggers event delivery to receiveMessages loop (see above). + if !msgReqs.isEmpty, + let rcvMsgs = apiGetConnNtfMessages(connMsgReqs: msgReqs.map { $0.connMsgReq }) { + for i in 0 ..< min(msgReqs.count, rcvMsgs.count) { // a sanity check, API always returns the same size + if rcvMsgs[i].noMsg { + // mark entity as ready if there are no message on the server (or on error) + finalizeEntity(msgReqs[i].chatId) + } } - return } } else if let dbStatus = dbStatus { setServiceBestAttemptNtf(createErrorNtf(dbStatus, badgeCount)) } } + // try to deliver the best attempt before exiting deliverBestAttemptNtf() } - func addExpectedMessage(ntfConn: UserNtfConn) { - if let connEntity = ntfConn.connEntity_, - let receiveEntityId = connEntity.id, ntfConn.expectedMsg_ != nil { - let expectedMsgId = ntfConn.expectedMsg_?.msgId - logger.debug("NotificationService: addExpectedMessage: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private)") - expectedMessages[receiveEntityId] = ExpectedMessage( + @inline(__always) + func mkNotificationEntity(ntfConn: NtfConn) -> NotificationEntity? { + if let rcvEntityId = ntfConn.connEntity.id { + // don't receive messages for muted user profile + let expectedMsg: NtfMsgInfo? = if ntfConn.user.showNotifications { ntfConn.expectedMsg_ } else { nil } + return NotificationEntity( ntfConn: ntfConn, - receiveConnId: connEntity.conn.agentConnId, - expectedMsgId: expectedMsgId, - allowedGetNextAttempts: 3, - msgBestAttemptNtf: ntfConn.defaultBestAttemptNtf, - ready: false, - shouldProcessNtf: false, - startedProcessingNewMsgs: false, - semaphore: DispatchSemaphore(value: 0) + entityId: rcvEntityId, + expectedMsg: expectedMsg, + msgBestAttemptNtf: defaultBestAttemptNtf(ntfConn) ) } + return nil } - func checkCanStart(_ entityId: String) -> Bool { - return !NSEThreads.shared.activeThreads.contains(where: { - (tId, nse) in tId != threadId && nse.expectedMessages.contains(where: { $0.key == entityId }) - }) - } - - func processDroppedNotifications(_ entityId: String) { - if !NSEThreads.shared.droppedNotifications.isEmpty { - let messagesToProcess = NSEThreads.shared.droppedNotifications.filter { (eId, _) in eId == entityId } - NSEThreads.shared.droppedNotifications.removeAll(where: { (eId, _) in eId == entityId }) - for (index, (_, ntf)) in messagesToProcess.enumerated() { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entity \(entityId, privacy: .private): processing dropped notification \(index, privacy: .private)") } - processReceivedNtf(entityId, ntf, signalReady: false) + // Processes notifications received and postponed by the previous NSE instance + func processDroppedNotifications(_ ntfEntity: NotificationEntity, _ expectedMsg: NtfMsgInfo) -> Bool { + var completed = false + while !completed { + if let dropped = NSEThreads.shared.takeDroppedNtf(ntfEntity) { + completed = processReceivedNtf(ntfEntity, expectedMsg, dropped.ntf) + } else { + break } } + if completed { + finalizeEntity(ntfEntity.entityId) + } else { + notificationEntities[ntfEntity.entityId]?.shouldProcessNtf = true + } + return completed } override func serviceExtensionTimeWillExpire() { @@ -326,73 +517,70 @@ class NotificationService: UNNotificationServiceExtension { deliverBestAttemptNtf(urgent: true) } + @inline(__always) var expectingMoreMessages: Bool { - !expectedMessages.allSatisfy { $0.value.ready } + notificationEntities.contains { $0.value.expectedMsg != nil } } - func processReceivedNtf(_ id: ChatId, _ ntf: NSENotificationData, signalReady: Bool) { - guard let expectedMessage = expectedMessages[id] else { - return - } - guard let expectedMsgTs = expectedMessage.ntfConn.expectedMsg_?.msgTs else { - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - return - } + // processReceivedNtf returns "completed" - true when no more messages for the passed entity should be processed by the current NSE instance. + // This is used to call finalizeEntity(id) and by processDroppedNotifications to decide if further processing is needed. + func processReceivedNtf(_ ntfEntity: NotificationEntity, _ expectedMsg: NtfMsgInfo, _ ntf: NSENotificationData) -> Bool { + let id = ntfEntity.entityId if case let .msgInfo(info) = ntf { - if info.msgId == expectedMessage.expectedMsgId { + if info.msgId == expectedMsg.msgId { + // The message for this instance is processed, no more expected, deliver. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): expected") - expectedMessages[id]?.expectedMsgId = nil - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() - } else if let msgTs = info.msgTs_, msgTs > expectedMsgTs { + return true + } else if let msgTs = info.msgTs_, msgTs > expectedMsg.msgTs { + // Otherwise check timestamp - if it is after the currently expected timestamp, preserve .msgInfo marker for the next instance. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, let other instance to process it, stopping this one") - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() - } else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let receiveConnId = expectedMessages[id]?.receiveConnId { + NSEThreads.shared.addDroppedNtf(id, ntf) + return true + } else if ntfEntity.allowedGetNextAttempts > 0, let connMsgReq = ntfEntity.connMsgReq { + // Otherwise this instance expects more messages, and still has allowed attempts - + // request more messages with getConnNtfMessage. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, get next message") - expectedMessages[id]?.allowedGetNextAttempts -= 1 - if let receivedMsg = getConnNtfMessage(connId: receiveConnId) { - logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)") + notificationEntities[id]?.allowedGetNextAttempts -= 1 + let receivedMsg = getConnNtfMessage(connMsgReq: connMsgReq) + if case let .info(msg) = receivedMsg, let msg { + // Server delivered message, it will be processed in the loop - see the comments in receiveNtfMessages. + logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(msg.msgId, privacy: .private)") + return false } else { + // Server reported no messages or error, deliver what we have. logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt") - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() + return true } } else { + // Current instance needs more messages, but ran out of attempts - deliver what we have. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unknown message, let other instance to process it") - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() + return true } - } else if expectedMessage.ntfConn.user.showNotifications { + } else if ntfEntity.ntfConn.user.showNotifications { + // This is the notification event for the user with enabled notifications. logger.debug("NotificationService processNtf: setting best attempt") if ntf.notificationEvent != nil { setBadgeCount() } - let prevBestAttempt = expectedMessages[id]?.msgBestAttemptNtf - if prevBestAttempt?.callInvitation != nil { - if ntf.callInvitation != nil { // replace with newer call - expectedMessages[id]?.msgBestAttemptNtf = ntf - } // otherwise keep call as best attempt - } else { - expectedMessages[id]?.msgBestAttemptNtf = ntf - } + // If previous "best attempt" is not a call, or if the current notification is a call, replace best attempt. + // NOTE: we are delaying it until notification marker to make sure we are not delivering stale calls that can't be connected. + // A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above. + // If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once, + // instead of requesting further messages. + if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil { + notificationEntities[id]?.msgBestAttemptNtf = ntf + } // otherwise keep call as best attempt + return false } else { - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } + // We should not get to this branch, as notifications are not delivered for muted users. + return true } } - func entityReady(_ entityId: ChatId) { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: entity \(entityId, privacy: .private)") } - expectedMessages[entityId]?.ready = true - if let (tNext, nse) = NSEThreads.shared.activeThreads.first(where: { (_, nse) in nse.expectedMessages[entityId]?.startedProcessingNewMsgs == false }) { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: signal next thread \(tNext, privacy: .private) for entity \(entityId, privacy: .private)") } - nse.expectedMessages[entityId]?.semaphore.signal() - } + func finalizeEntity(_ entityId: ChatId) { + if let t = threadId { logger.debug("NotificationService thread \(t): entityReady: entity \(entityId)") } + NSEThreads.shared.signalNextThread(self, entityId) + deliverBestAttemptNtf() } func setBadgeCount() { @@ -400,17 +588,22 @@ class NotificationService: UNNotificationServiceExtension { ntfBadgeCountGroupDefault.set(badgeCount) } + @inline(__always) func setServiceBestAttemptNtf(_ ntf: UNMutableNotificationContent) { logger.debug("NotificationService.setServiceBestAttemptNtf") - serviceBestAttemptNtf = .nse(ntf) + serviceBestAttemptNtf = ntf } private func deliverBestAttemptNtf(urgent: Bool = false) { - if (urgent || !expectingMoreMessages) { + logger.debug("NotificationService.deliverBestAttemptNtf urgent: \(urgent) expectingMoreMessages: \(self.expectingMoreMessages)") + if let handler = contentHandler, urgent || !expectingMoreMessages { + if urgent { + contentHandler = nil + } logger.debug("NotificationService.deliverBestAttemptNtf") // stop processing other messages - for (key, _) in expectedMessages { - expectedMessages[key]?.shouldProcessNtf = false + for (key, _) in notificationEntities { + notificationEntities[key]?.shouldProcessNtf = false } let suspend: Bool @@ -420,26 +613,28 @@ class NotificationService: UNNotificationServiceExtension { } else { suspend = false } - deliverCallkitOrNotification(urgent: urgent, suspend: suspend) + deliverCallkitOrNotification(urgent: urgent, suspend: suspend, handler: handler) } } - private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false) { - if useCallKit() && expectedMessages.contains(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }) { + @inline(__always) + private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false, handler: @escaping (UNNotificationContent) -> Void) { + let callInv = notificationEntities.lazy.compactMap({ $0.value.msgBestAttemptNtf.callInvitation }).first + if callInv != nil && useCallKit() { logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit") + // suspending NSE even though there may be other notifications + // to allow the app to process callkit call if urgent { - // suspending NSE even though there may be other notifications - // to allow the app to process callkit call suspendChat(0) - deliverNotification() + deliverNotification(handler, callInv) } else { - // suspending NSE with delay and delivering after the suspension + // when not "urgent", suspending NSE with delay and delivering after the suspension // because pushkit notification must be processed without delay - // to avoid app termination + // to avoid app termination. DispatchQueue.global().asyncAfter(deadline: .now() + fastNSESuspendSchedule.delay) { suspendChat(fastNSESuspendSchedule.timeout) DispatchQueue.global().asyncAfter(deadline: .now() + Double(fastNSESuspendSchedule.timeout)) { - self.deliverNotification() + self.deliverNotification(handler, callInv) } } } @@ -458,66 +653,71 @@ class NotificationService: UNNotificationServiceExtension { } } } - deliverNotification() + deliverNotification(handler, callInv) } } - private func deliverNotification() { - if let handler = contentHandler, let ntf = prepareNotification() { - contentHandler = nil + private func deliverNotification(_ handler: @escaping (UNNotificationContent) -> Void, _ callInv: RcvCallInvitation?) { + if let serviceNtf = serviceBestAttemptNtf { serviceBestAttemptNtf = nil - switch ntf { - case let .nse(content): - content.badge = badgeCount as NSNumber - handler(content) - case let .callkit(invitation): - logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(invitation.contact.id)") - CXProvider.reportNewIncomingVoIPPushPayload([ - "displayName": invitation.contact.displayName, - "contactId": invitation.contact.id, - "callUUID": invitation.callUUID ?? "", - "media": invitation.callType.media.rawValue, - "callTs": invitation.callTs.timeIntervalSince1970 - ]) { error in - logger.debug("reportNewIncomingVoIPPushPayload result: \(error)") - handler(error == nil ? UNMutableNotificationContent() : createCallInvitationNtf(invitation, self.badgeCount)) + contentHandler = nil + if let callInv { + if useCallKit() { + logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(callInv.contact.id)") + CXProvider.reportNewIncomingVoIPPushPayload([ + "displayName": callInv.contact.displayName, + "contactId": callInv.contact.id, + "callUUID": callInv.callUUID ?? "", + "media": callInv.callType.media.rawValue, + "callTs": callInv.callTs.timeIntervalSince1970 + ]) { error in + logger.debug("reportNewIncomingVoIPPushPayload result: \(error)") + handler(error == nil ? UNMutableNotificationContent() : createCallInvitationNtf(callInv, self.badgeCount)) + } + } else { + handler(createCallInvitationNtf(callInv, badgeCount)) } - case .empty: - handler(UNMutableNotificationContent()) // used to mute notifications that did not unsubscribe yet - } - } - } - - private func prepareNotification() -> NSENotification? { - if expectedMessages.isEmpty { - return serviceBestAttemptNtf - } else if let callNtfKV = expectedMessages.first(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }), - let callInv = callNtfKV.value.msgBestAttemptNtf?.callInvitation, - let callNtf = callNtfKV.value.msgBestAttemptNtf { - return useCallKit() ? .callkit(callInv) : .nse(callNtf.notificationContent(badgeCount)) - } else { - let ntfEvents = expectedMessages.compactMap { $0.value.msgBestAttemptNtf?.notificationEvent } - if ntfEvents.isEmpty { - return .empty - } else if let ntfEvent = ntfEvents.count == 1 ? ntfEvents.first : nil { - return .nse(ntfEvent.notificationContent(badgeCount)) + } else if notificationEntities.isEmpty { + handler(serviceNtf) } else { - return .nse(createJointNtf(ntfEvents)) + handler(prepareNotification()) } } } - private func createJointNtf(_ ntfEvents: [NSENotificationData]) -> UNMutableNotificationContent { + @inline(__always) + private func prepareNotification() -> UNMutableNotificationContent { + // uncomment localDisplayName in ConnectionEntity + // let conns = self.notificationEntities.compactMap { $0.value.ntfConn.connEntity.localDisplayName } + // logger.debug("NotificationService prepareNotification for \(String(describing: conns))") + let ntfs = notificationEntities.compactMap { $0.value.msgBestAttemptNtf.notificationEvent } + let newMsgNtfs = ntfs.compactMap({ $0.newMsgNtf }) + let useNtfs = if newMsgNtfs.isEmpty { ntfs } else { newMsgNtfs } + return createNtf(useNtfs) + + func createNtf(_ ntfs: [NSENotificationData]) -> UNMutableNotificationContent { + logger.debug("NotificationService prepareNotification: \(ntfs.count) events") + return switch ntfs.count { + case 0: UNMutableNotificationContent() // used to mute notifications that did not unsubscribe yet + case 1: ntfs[0].notificationContent(badgeCount) + default: createJointNtf(ntfs) + } + } + } + + // NOTE: this can be improved when there are two or more connection entity events when no messages were delivered. + // Possibly, it is better to postpone this improvement until message priority is added to prevent notifications in muted groups, + // unless it is a mention, a reply or some other high priority message marked for notification delivery. + @inline(__always) + private func createJointNtf(_ ntfs: [NSENotificationData]) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() - let newMsgsData: [(any UserLike, ChatInfo)] = ntfEvents.compactMap { $0.newMsgData } - if !newMsgsData.isEmpty, let userId = newMsgsData.first?.0.userId { - let newMsgsChats: [ChatInfo] = newMsgsData.map { $0.1 } - let uniqueChatsNames = uniqueNewMsgsChatsNames(newMsgsChats) - var body: String - if previewMode == .hidden { - body = String.localizedStringWithFormat(NSLocalizedString("New messages in %d chats", comment: "notification body"), uniqueChatsNames.count) + logger.debug("NotificationService.createJointNtf ntfs: \(ntfs.count)") + let (userId, chatsNames) = newMsgsChatsNames(ntfs) + if !chatsNames.isEmpty, let userId { + let body = if previewMode == .hidden { + String.localizedStringWithFormat(NSLocalizedString("From %d chat(s)", comment: "notification body"), chatsNames.count) } else { - body = String.localizedStringWithFormat(NSLocalizedString("From: %@", comment: "notification body"), newMsgsChatsNamesStr(uniqueChatsNames)) + String.localizedStringWithFormat(NSLocalizedString("From: %@", comment: "notification body"), newMsgsChatsNamesStr(chatsNames)) } return createNotification( categoryIdentifier: ntfCategoryManyEvents, @@ -530,24 +730,32 @@ class NotificationService: UNNotificationServiceExtension { return createNotification( categoryIdentifier: ntfCategoryManyEvents, title: NSLocalizedString("New events", comment: "notification"), - body: String.localizedStringWithFormat(NSLocalizedString("%d new events", comment: "notification body"), ntfEvents.count), + body: String.localizedStringWithFormat(NSLocalizedString("%d new events", comment: "notification body"), ntfs.count), badgeCount: badgeCount ) } } - private func uniqueNewMsgsChatsNames(_ newMsgsChats: [ChatInfo]) -> [String] { + @inline(__always) + private func newMsgsChatsNames(_ ntfs: [NSENotificationData]) -> (Int64?, [String]) { var seenChatIds = Set() - var uniqueChatsNames: [String] = [] - for chat in newMsgsChats { - if !seenChatIds.contains(chat.id) { - seenChatIds.insert(chat.id) - uniqueChatsNames.append(chat.chatViewName) + var chatsNames: [String] = [] + var userId: Int64? + for ntf in ntfs { + switch ntf { + case let .messageReceived(user, chat, _): + if seenChatIds.isEmpty { userId = user.userId } + if !seenChatIds.contains(chat.id) { + seenChatIds.insert(chat.id) + chatsNames.append(chat.chatViewName) + } + default: () } } - return uniqueChatsNames + return (userId, chatsNames) } + @inline(__always) private func newMsgsChatsNamesStr(_ names: [String]) -> String { return switch names.count { case 1: names[0] @@ -566,9 +774,8 @@ class NSEChatState { static let shared = NSEChatState() private var value_ = NSEState.created - var value: NSEState { - value_ - } + @inline(__always) + var value: NSEState { value_ } func set(_ state: NSEState) { nseStateGroupDefault.set(state) @@ -576,7 +783,7 @@ class NSEChatState { value_ = state } - init() { + private init() { // This is always set to .created state, as in case previous start of NSE crashed in .active state, it is stored correctly. // Otherwise the app will be activating slower set(.created) @@ -624,7 +831,7 @@ func startChat() -> DBMigrationResult? { startLock.wait() defer { startLock.signal() } - + if hasChatCtrl() { return switch NSEChatState.shared.value { case .created: doStartChat() @@ -654,7 +861,7 @@ func doStartChat() -> DBMigrationResult? { let state = NSEChatState.shared.value NSEChatState.shared.set(.starting) if let user = apiGetActiveUser() { - logger.debug("NotificationService active user \(String(describing: user))") + logger.debug("NotificationService active user \(user.displayName)") do { try setNetworkConfig(networkConfig) try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path) @@ -747,12 +954,18 @@ func receiveMessages() async { } func receiveMsg() async { - if let msg = await chatRecvMsg() { + switch await chatRecvMsg() { + case let .result(msg): logger.debug("NotificationService receiveMsg: message") if let (id, ntf) = await receivedMsgNtf(msg) { logger.debug("NotificationService receiveMsg: notification") await NSEThreads.shared.processNotification(id, ntf) } + case let .error(err): + logger.error("NotificationService receivedMsgNtf error: \(String(describing: err))") + case let .invalid(type, _): + logger.error("NotificationService receivedMsgNtf invalid: \(type)") + case .none: () } } @@ -762,17 +975,20 @@ func receiveMessages() async { } } -func chatRecvMsg() async -> ChatResponse? { +func chatRecvMsg() async -> APIResult? { await withCheckedContinuation { cont in - let resp = recvSimpleXMsg() + let resp: APIResult? = recvSimpleXMsg() cont.resume(returning: resp) } } private let isInChina = SKStorefront().countryCode == "CHN" + +@inline(__always) private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } -func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? { +@inline(__always) +func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? { logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") switch res { case let .contactConnected(user, contact, _): @@ -789,8 +1005,13 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? if let file = cItem.autoReceiveFile() { cItem = autoReceiveFile(file) ?? cItem } - let ntf: NSENotificationData = (cInfo.ntfsEnabled && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - return (chatItem.chatId, ntf) + let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf + let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { + groupMember.id + } else { + chatItem.chatInfo.id + } + return (chatIdOrMemberId, ntf) } else { return nil } @@ -813,15 +1034,10 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? case .chatSuspended: chatSuspended() return nil - case let .chatError(_, err): - logger.error("NotificationService receivedMsgNtf error: \(String(describing: err))") - return nil - default: - logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)") - return nil } } +@inline(__always) func updateNetCfg() { let newNetConfig = getNetCfg() if newNetConfig != networkConfig { @@ -836,14 +1052,14 @@ func updateNetCfg() { } func apiGetActiveUser() -> User? { - let r = sendSimpleXCmd(.showActiveUser) + let r: APIResult = sendSimpleXCmd(NSEChatCommand.showActiveUser) logger.debug("apiGetActiveUser sendSimpleXCmd response: \(r.responseType)") switch r { - case let .activeUser(user): return user - case .chatCmdError(_, .error(.noActiveUser)): + case let .result(.activeUser(user)): return user + case .error(.error(.noActiveUser)): logger.debug("apiGetActiveUser sendSimpleXCmd no active user") return nil - case let .chatCmdError(_, err): + case let .error(err): logger.debug("apiGetActiveUser sendSimpleXCmd error: \(String(describing: err))") return nil default: @@ -853,101 +1069,93 @@ func apiGetActiveUser() -> User? { } func apiStartChat() throws -> Bool { - let r = sendSimpleXCmd(.startChat(mainApp: false, enableSndFiles: false)) + let r: APIResult = sendSimpleXCmd(NSEChatCommand.startChat(mainApp: false, enableSndFiles: false)) switch r { - case .chatStarted: return true - case .chatRunning: return false - default: throw r + case .result(.chatStarted): return true + case .result(.chatRunning): return false + default: throw r.unexpected } } func apiActivateChat() -> Bool { chatReopenStore() - let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) - if case .cmdOk = r { return true } + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiActivateChat(restoreChat: false)) + if case .result(.cmdOk) = r { return true } logger.error("NotificationService apiActivateChat error: \(String(describing: r))") return false } func apiSuspendChat(timeoutMicroseconds: Int) -> Bool { - let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) - if case .cmdOk = r { return true } + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + if case .result(.cmdOk) = r { return true } logger.error("NotificationService apiSuspendChat error: \(String(describing: r))") return false } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws { - let r = sendSimpleXCmd(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSetEncryptLocalFiles(enable: enable)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } -func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? { +func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [NtfConn]? { + guard apiGetActiveUser() != nil else { + logger.debug("NotificationService: no active user") + return nil + } + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .result(.ntfConns(ntfConns)) = r { + logger.debug("NotificationService apiGetNtfConns response ntfConns: \(ntfConns.count) conections") + return ntfConns + } else if case let .error(error) = r { + logger.debug("NotificationService apiGetNtfMessage error response: \(String.init(describing: error))") + } else { + logger.debug("NotificationService apiGetNtfMessage ignored response: \(r.responseType) \(String.init(describing: r))") + } + return nil +} + +func apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) -> [RcvNtfMsgInfo]? { guard apiGetActiveUser() != nil else { logger.debug("no active user") return nil } - let r = sendSimpleXCmd(.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfConns(ntfConns) = r { - logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)") - return ntfConns.compactMap { toUserNtfConn($0) } - } else if case let .chatCmdError(_, error) = r { - logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") - } else { - logger.debug("apiGetNtfMessage ignored response: \(r.responseType) \(String.init(describing: r))") +// logger.debug("NotificationService apiGetConnNtfMessages command: \(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)") + logger.debug("NotificationService apiGetConnNtfMessages requests: \(connMsgReqs.count)") + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs)) + if case let .result(.connNtfMessages(msgs)) = r { +// logger.debug("NotificationService apiGetConnNtfMessages responses: \(String(describing: msgs))") + logger.debug("NotificationService apiGetConnNtfMessages responses: total \(msgs.count), expecting messages \(msgs.count { !$0.noMsg }), errors \(msgs.count { $0.isError })") + return msgs } + logger.debug("NotificationService apiGetConnNtfMessages error: \(responseError(r.unexpected))") return nil } -func toUserNtfConn(_ ntfConn: NtfConn) -> UserNtfConn? { - if let user = ntfConn.user_ { - return UserNtfConn(user: user, connEntity_: ntfConn.connEntity_, expectedMsg_: ntfConn.expectedMsg_) - } else { - return nil - } -} - -func apiGetConnNtfMessages(connIds: [String]) -> [NtfMsgInfo?]? { - guard apiGetActiveUser() != nil else { - logger.debug("no active user") - return nil - } - let r = sendSimpleXCmd(.apiGetConnNtfMessages(connIds: connIds)) - if case let .connNtfMessages(receivedMsgs) = r { - logger.debug("apiGetConnNtfMessages response receivedMsgs: \(receivedMsgs.count)") - return receivedMsgs - } - logger.debug("apiGetConnNtfMessages error: \(responseError(r))") - return nil -} - -func getConnNtfMessage(connId: String) -> NtfMsgInfo? { - let r_ = apiGetConnNtfMessages(connIds: [connId]) - if let r = r_, let receivedMsg = r.count == 1 ? r.first : nil { - return receivedMsg - } - return nil +func getConnNtfMessage(connMsgReq: ConnMsgReq) -> RcvNtfMsgInfo? { + let r = apiGetConnNtfMessages(connMsgReqs: [connMsgReq]) + return if let r, r.count > 0 { r[0] } else { nil } } func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? { let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get() - let r = sendSimpleXCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) - if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } - logger.error("receiveFile error: \(responseError(r))") + let r: APIResult = sendSimpleXCmd(NSEChatCommand.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) + if case let .result(.rcvFileAccepted(_, chatItem)) = r { return chatItem } + logger.error("receiveFile error: \(responseError(r.unexpected))") return nil } func apiSetFileToReceive(fileId: Int64, encrypted: Bool) { let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get() - let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted)) - if case .cmdOk = r { return } - logger.error("setFileToReceive error: \(responseError(r))") + let r: APIResult = sendSimpleXCmd(NSEChatCommand.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted)) + if case .result(.cmdOk) = r { return } + logger.error("setFileToReceive error: \(responseError(r.unexpected))") } func autoReceiveFile(_ file: CIFile) -> ChatItem? { @@ -964,38 +1172,32 @@ func autoReceiveFile(_ file: CIFile) -> ChatItem? { } func setNetworkConfig(_ cfg: NetCfg) throws { - let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } -struct UserNtfConn { - var user: User - var connEntity_: ConnectionEntity? - var expectedMsg_: NtfMsgInfo? - - var defaultBestAttemptNtf: NSENotificationData { - return if !user.showNotifications { - .noNtf - } else if let connEntity = connEntity_ { - switch connEntity { - case let .rcvDirectMsgConnection(_, contact): - contact?.chatSettings.enableNtfs == .all - ? .connectionEvent(user, connEntity) - : .noNtf - case let .rcvGroupMsgConnection(_, groupInfo, _): - groupInfo.chatSettings.enableNtfs == .all - ? .connectionEvent(user, connEntity) - : .noNtf - case .sndFileConnection: .noNtf - case .rcvFileConnection: .noNtf - case let .userContactConnection(_, userContact): - userContact.groupId == nil - ? .connectionEvent(user, connEntity) - : .noNtf - } - } else { - .noNtf +func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData { + let user = ntfConn.user + let connEntity = ntfConn.connEntity + return if !user.showNotifications { + .noNtf + } else { + switch ntfConn.connEntity { + case let .rcvDirectMsgConnection(_, contact): + contact?.chatSettings.enableNtfs == .all + ? .connectionEvent(user, connEntity) + : .noNtf + case let .rcvGroupMsgConnection(_, groupInfo, _): + groupInfo.chatSettings.enableNtfs == .all + ? .connectionEvent(user, connEntity) + : .noNtf + case .sndFileConnection: .noNtf + case .rcvFileConnection: .noNtf + case let .userContactConnection(_, userContact): + userContact.groupId == nil + ? .connectionEvent(user, connEntity) + : .noNtf } } } diff --git a/apps/ios/SimpleX NSE/de.lproj/Localizable.strings b/apps/ios/SimpleX NSE/de.lproj/Localizable.strings index f9779c6e05..ec502c53c6 100644 --- a/apps/ios/SimpleX NSE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/de.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d neue Ereignisse"; +/* notification body */ +"From %d chat(s)" = "Von %d Chat(s)"; + /* notification body */ "From: %@" = "Von: %@"; @@ -10,6 +13,3 @@ /* notification */ "New messages" = "Neue Nachrichten"; -/* notification body */ -"New messages in %d chats" = "Neue Nachrichten in %d Chats"; - diff --git a/apps/ios/SimpleX NSE/es.lproj/Localizable.strings b/apps/ios/SimpleX NSE/es.lproj/Localizable.strings index fb190400e1..685eb3d93d 100644 --- a/apps/ios/SimpleX NSE/es.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/es.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d evento(s) nuevo(s)"; +/* notification body */ +"From %d chat(s)" = "De %d chat(s)"; + /* notification body */ "From: %@" = "De: %@"; @@ -10,6 +13,3 @@ /* notification */ "New messages" = "Mensajes nuevos"; -/* notification body */ -"New messages in %d chats" = "Mensajes nuevos en %d chat(s)"; - diff --git a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings index 5ef592ec70..999bb3608f 100644 --- a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings @@ -1,7 +1,12 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"%d new events" = "%d nouveaux événements"; + +/* notification body */ +"From: %@" = "De : %@"; + +/* notification */ +"New events" = "Nouveaux événements"; + +/* notification */ +"New messages" = "Nouveaux messages"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings b/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings index e64c98df9e..a6330b93db 100644 --- a/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d új esemény"; +/* notification body */ +"From %d chat(s)" = "%d csevegésből"; + /* notification body */ "From: %@" = "Tőle: %@"; @@ -10,6 +13,3 @@ /* notification */ "New messages" = "Új üzenetek"; -/* notification body */ -"New messages in %d chats" = "Új üzenetek %d csevegésben"; - diff --git a/apps/ios/SimpleX NSE/it.lproj/Localizable.strings b/apps/ios/SimpleX NSE/it.lproj/Localizable.strings index 31f463eb5b..a6c1ec215b 100644 --- a/apps/ios/SimpleX NSE/it.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/it.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d nuovi eventi"; +/* notification body */ +"From %d chat(s)" = "Da %d chat"; + /* notification body */ "From: %@" = "Da: %@"; @@ -10,6 +13,3 @@ /* notification */ "New messages" = "Nuovi messaggi"; -/* notification body */ -"New messages in %d chats" = "Nuovi messaggi in %d chat"; - diff --git a/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings b/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings index 4cf91689b5..12d1e01f1d 100644 --- a/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Nieuwe berichten"; -/* notification body */ -"New messages in %d chats" = "Nieuwe berichten in %d chats"; - diff --git a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings index 5ef592ec70..3a577620a0 100644 --- a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings @@ -1,7 +1,3 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"New messages in %d chats" = "Nowe wiadomości w %d czatach"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index 6ba39ccc63..cf082a166d 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d новых сообщений"; +/* notification body */ +"From %d chat(s)" = "Из %d чатов"; + /* notification body */ "From: %@" = "От: %@"; @@ -10,6 +13,3 @@ /* notification */ "New messages" = "Новые сообщения"; -/* notification body */ -"New messages in %d chats" = "Новые сообщения в %d разговоре(ах)"; - diff --git a/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings b/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings index 69cc53bff1..ceace71e34 100644 --- a/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Нові повідомлення"; -/* notification body */ -"New messages in %d chats" = "Нові повідомлення в чатах %d"; - diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index fcb78c64b1..2b3e8068ae 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -13,91 +13,93 @@ import SimpleXChat let logger = Logger() func apiGetActiveUser() throws -> User? { - let r = sendSimpleXCmd(.showActiveUser) + let r: APIResult = sendSimpleXCmd(SEChatCommand.showActiveUser) switch r { - case let .activeUser(user): return user - case .chatCmdError(_, .error(.noActiveUser)): return nil - default: throw r + case let .result(.activeUser(user)): return user + case .error(.error(.noActiveUser)): return nil + default: throw r.unexpected } } func apiStartChat() throws -> Bool { - let r = sendSimpleXCmd(.startChat(mainApp: false, enableSndFiles: true)) + let r: APIResult = sendSimpleXCmd(SEChatCommand.startChat(mainApp: false, enableSndFiles: true)) switch r { - case .chatStarted: return true - case .chatRunning: return false - default: throw r + case .result(.chatStarted): return true + case .result(.chatRunning): return false + default: throw r.unexpected } } func apiSetNetworkConfig(_ cfg: NetCfg) throws { - let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws { - let r = sendSimpleXCmd(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSetEncryptLocalFiles(enable: enable)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiGetChats(userId: User.ID) throws -> Array { - let r = sendSimpleXCmd(.apiGetChats(userId: userId)) - if case let .apiChats(user: _, chats: chats) = r { return chats } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiGetChats(userId: userId)) + if case let .result(.apiChats(user: _, chats: chats)) = r { return chats } + throw r.unexpected } func apiSendMessages( chatInfo: ChatInfo, composedMessages: [ComposedMessage] ) throws -> [AChatItem] { - let r = sendSimpleXCmd( + let r: APIResult = sendSimpleXCmd( chatInfo.chatType == .local - ? .apiCreateChatItems( + ? SEChatCommand.apiCreateChatItems( noteFolderId: chatInfo.apiId, composedMessages: composedMessages ) - : .apiSendMessages( + : SEChatCommand.apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, + scope: chatInfo.groupChatScope(), live: false, ttl: nil, composedMessages: composedMessages ) ) - if case let .newChatItems(_, chatItems) = r { + if case let .result(.newChatItems(_, chatItems)) = r { return chatItems } else { for composedMessage in composedMessages { if let filePath = composedMessage.fileSource?.filePath { removeFile(filePath) } } - throw r + throw r.unexpected } } func apiActivateChat() throws { chatReopenStore() - let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiActivateChat(restoreChat: false)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSuspendChat(expired: Bool) { - let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000)) + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000)) // Block until `chatSuspended` received or 3 seconds has passed var suspended = false - if case .cmdOk = r, !expired { + if case .result(.cmdOk) = r, !expired { let startTime = CFAbsoluteTimeGetCurrent() while CFAbsoluteTimeGetCurrent() - startTime < 3 { - switch recvSimpleXMsg(messageTimeout: 3_500000) { - case .chatSuspended: + let msg: APIResult? = recvSimpleXMsg(messageTimeout: 3_500000) + switch msg { + case .result(.chatSuspended): suspended = false break default: continue @@ -105,9 +107,133 @@ func apiSuspendChat(expired: Bool) { } } if !suspended { - _ = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: 0)) + let _r1: APIResult = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: 0)) } logger.debug("close store") chatCloseStore() SEChatState.shared.set(.inactive) } + +enum SEChatCommand: ChatCmdProtocol { + case showActiveUser + case startChat(mainApp: Bool, enableSndFiles: Bool) + case apiActivateChat(restoreChat: Bool) + case apiSuspendChat(timeoutMicroseconds: Int) + case apiSetNetworkConfig(networkConfig: NetCfg) + case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) + case apiSetEncryptLocalFiles(enable: Bool) + case apiGetChats(userId: Int64) + case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + + var cmdString: String { + switch self { + case .showActiveUser: return "/u" + case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" + case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" + case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): + return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" + case let .apiCreateChatItems(noteFolderId, composedMessages): + let msgs = encodeJSON(composedMessages) + return "/_create *\(noteFolderId) json \(msgs)" + case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + let msgs = encodeJSON(composedMessages) + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + } + } + + func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String { + "\(type.rawValue)\(id)\(scopeRef(scope: scope))" + } + + func scopeRef(scope: GroupChatScope?) -> String { + switch (scope) { + case .none: "" + case let .memberSupport(groupMemberId_): + if let groupMemberId = groupMemberId_ { + "(_support:\(groupMemberId))" + } else { + "(_support)" + } + } + } +} + +enum SEChatResponse: Decodable, ChatAPIResult { + case activeUser(user: User) + case chatStarted + case chatRunning + case apiChats(user: UserRef, chats: [ChatData]) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case cmdOk(user_: UserRef?) + + var responseType: String { + switch self { + case .activeUser: "activeUser" + case .chatStarted: "chatStarted" + case .chatRunning: "chatRunning" + case .apiChats: "apiChats" + case .newChatItems: "newChatItems" + case .cmdOk: "cmdOk" + } + } + + var details: String { + switch self { + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case .cmdOk: return noDetails + } + } + + static func fallbackResult(_ type: String, _ json: NSDictionary) -> SEChatResponse? { + if type == "apiChats", let r = parseApiChats(json) { + .apiChats(user: r.user, chats: r.chats) + } else { + nil + } + } +} + +enum SEChatEvent: Decodable, ChatAPIResult { + case chatSuspended + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) + case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + + var responseType: String { + switch self { + case .chatSuspended: "chatSuspended" + case .sndFileProgressXFTP: "sndFileProgressXFTP" + case .sndFileCompleteXFTP: "sndFileCompleteXFTP" + case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" + case .sndFileError: "sndFileError" + case .sndFileWarning: "sndFileWarning" + } + } + + var details: String { + switch self { + case .chatSuspended: return noDetails + case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .chatItemsStatusesUpdated(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + } + } +} diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 21908026b2..12a775f85c 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -179,7 +179,7 @@ class ShareModel: ObservableObject { resetChatCtrl() // Clears retained migration result registerGroupDefaults() haskell_init_se() - let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation()) + let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation(), backgroundMode: false) if let e = migrationError(result) { return e } try apiSetAppFilePaths( filesFolder: getAppFilesDirectory().path, @@ -303,8 +303,9 @@ class ShareModel: ObservableObject { } } } - switch recvSimpleXMsg(messageTimeout: 1_000_000) { - case let .sndFileProgressXFTP(_, ci, _, sentSize, totalSize): + let r: APIResult? = recvSimpleXMsg(messageTimeout: 1_000_000) + switch r { + case let .result(.sndFileProgressXFTP(_, ci, _, sentSize, totalSize)): guard isMessage(for: ci) else { continue } networkTimeout = CFAbsoluteTimeGetCurrent() await MainActor.run { @@ -313,14 +314,14 @@ class ShareModel: ObservableObject { bottomBar = .loadingBar(progress: progress) } } - case let .sndFileCompleteXFTP(_, ci, _): + case let .result(.sndFileCompleteXFTP(_, ci, _)): guard isMessage(for: ci) else { continue } if isGroupChat { await MainActor.run { bottomBar = .loadingSpinner } } await ch.completeFile() if await !ch.isRunning { break } - case let .chatItemsStatusesUpdated(_, chatItems): + case let .result(.chatItemsStatusesUpdated(_, chatItems)): guard let ci = chatItems.last else { continue } guard isMessage(for: ci) else { continue } if let (title, message) = ci.chatItem.meta.itemStatus.statusInfo { @@ -342,17 +343,15 @@ class ShareModel: ObservableObject { } } } - case let .sndFileError(_, ci, _, errorMessage): + case let .result(.sndFileError(_, ci, _, errorMessage)): guard isMessage(for: ci) else { continue } if let ci { cleanupFile(ci) } return ErrorAlert(title: "File error", message: "\(fileErrorInfo(ci) ?? errorMessage)") - case let .sndFileWarning(_, ci, _, errorMessage): + case let .result(.sndFileWarning(_, ci, _, errorMessage)): guard isMessage(for: ci) else { continue } if let ci { cleanupFile(ci) } return ErrorAlert(title: "File error", message: "\(fileErrorInfo(ci) ?? errorMessage)") - case let .chatError(_, chatError): - return ErrorAlert(chatError) - case let .chatCmdError(_, chatError): + case let .error(chatError): return ErrorAlert(chatError) default: continue } diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift index f2b9de9f72..07180ffa1b 100644 --- a/apps/ios/SimpleX SE/ShareView.swift +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -160,7 +160,7 @@ struct ShareView: View { } } - @ViewBuilder private func linkPreview(_ linkPreview: LinkPreview) -> some View { + private func linkPreview(_ linkPreview: LinkPreview) -> some View { previewArea { HStack(alignment: .center, spacing: 8) { if let uiImage = imageFromBase64(linkPreview.image) { diff --git a/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings b/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings index 48f774742e..4a387a4361 100644 --- a/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings +++ b/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings @@ -5,5 +5,5 @@ "CFBundleName" = "SimpleX SE"; /* Copyright (human-readable) */ -"NSHumanReadableCopyright" = "Copyright © 2024 SimpleX Chat. Alle Rechte vorbehalten."; +"NSHumanReadableCopyright" = "Copyright © 2025 SimpleX Chat. Alle Rechte vorbehalten."; diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index 081d7f8c66..4c10694986 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -17,13 +17,13 @@ "Comment" = "Kommentieren"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Die maximale erlaubte Dateigröße beträgt aktuell %@."; +"Currently maximum supported file size is %@." = "Die maximal erlaubte Dateigröße beträgt aktuell %@."; /* No comment provided by engineer. */ -"Database downgrade required" = "Datenbank-Herabstufung erforderlich"; +"Database downgrade required" = "Datenbank-Herunterstufung ist erforderlich"; /* No comment provided by engineer. */ -"Database encrypted!" = "Datenbank verschlüsselt!"; +"Database encrypted!" = "Datenbank ist verschlüsselt!"; /* No comment provided by engineer. */ "Database error" = "Datenbankfehler"; @@ -32,7 +32,7 @@ "Database passphrase is different from saved in the keychain." = "Das Datenbank-Passwort unterscheidet sich vom im Schlüsselbund gespeicherten."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "Ein Datenbank-Passwort ist erforderlich, um den Chat zu öffnen."; +"Database passphrase is required to open chat." = "Um den Chat zu öffnen, ist ein Datenbank-Passwort ist erforderlich."; /* No comment provided by engineer. */ "Database upgrade required" = "Datenbank-Aktualisierung erforderlich"; @@ -47,7 +47,7 @@ "Error: %@" = "Fehler: %@"; /* No comment provided by engineer. */ -"File error" = "Dateifehler"; +"File error" = "Datei-Fehler"; /* No comment provided by engineer. */ "Incompatible database version" = "Datenbank-Version nicht kompatibel"; @@ -68,16 +68,16 @@ "Ok" = "OK"; /* No comment provided by engineer. */ -"Open the app to downgrade the database." = "Öffne die App, um die Datenbank herabzustufen."; +"Open the app to downgrade the database." = "Öffnen Sie die App, um die Datenbank herunterzustufen."; /* No comment provided by engineer. */ -"Open the app to upgrade the database." = "Öffne die App, um die Datenbank zu aktualisieren."; +"Open the app to upgrade the database." = "Öffnen Sie die App, um die Datenbank zu aktualisieren."; /* No comment provided by engineer. */ "Passphrase" = "Passwort"; /* No comment provided by engineer. */ -"Please create a profile in the SimpleX app" = "Bitte erstelle ein Profil in der SimpleX-App"; +"Please create a profile in the SimpleX app" = "Bitte erstellen Sie ein Profil in der SimpleX-App"; /* No comment provided by engineer. */ "Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt."; @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Falsches Datenbank-Passwort"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Du kannst das Teilen in den Einstellungen zu Datenschutz & Sicherheit - SimpleX-Sperre erlauben."; +"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben."; diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 94f18db853..2fedf0e6f1 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -2,7 +2,7 @@ "%@" = "%@"; /* No comment provided by engineer. */ -"App is locked!" = "Az alkalmazás zárolva!"; +"App is locked!" = "Az alkalmazás zárolva van!"; /* No comment provided by engineer. */ "Cancel" = "Mégse"; @@ -17,7 +17,7 @@ "Comment" = "Hozzászólás"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Jelenleg a maximálisan támogatott fájlméret: %@."; +"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájl méret: %@."; /* No comment provided by engineer. */ "Database downgrade required" = "Adatbázis visszafejlesztése szükséges"; @@ -29,19 +29,19 @@ "Database error" = "Adatbázishiba"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban lévőtől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "Adatbázis-jelmondat szükséges a csevegés megnyitásához."; +"Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; /* No comment provided by engineer. */ "Database upgrade required" = "Adatbázis fejlesztése szükséges"; /* No comment provided by engineer. */ -"Error preparing file" = "Hiba a fájl előkészítésekor"; +"Error preparing file" = "Hiba történt a fájl előkészítésekor"; /* No comment provided by engineer. */ -"Error preparing message" = "Hiba az üzenet előkészítésekor"; +"Error preparing message" = "Hiba történt az üzenet előkészítésekor"; /* No comment provided by engineer. */ "Error: %@" = "Hiba: %@"; @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Hozzon létre egy profilt a SimpleX alkalmazásban"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Az üzenet elküldése a vártnál tovább tart."; @@ -92,7 +92,7 @@ "Share" = "Megosztás"; /* No comment provided by engineer. */ -"Slow network?" = "Lassú internetkapcsolat?"; +"Slow network?" = "Lassú a hálózata?"; /* No comment provided by engineer. */ "Unknown database error: %@" = "Ismeretlen adatbázishiba: %@"; @@ -104,7 +104,7 @@ "Wait" = "Várjon"; /* No comment provided by engineer. */ -"Wrong database passphrase" = "Hibás adatbázis-jelmondat"; +"Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* No comment provided by engineer. */ "You can allow sharing in Privacy & Security / SimpleX Lock settings." = "A megosztást az Adatvédelem és biztonság / SimpleX-zár menüben engedélyezheti."; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 641c1e9fd1..42e36a78c9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -166,17 +166,22 @@ 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; - 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */; }; - 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */; }; - 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; + 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; }; + 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */; }; + 64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */; }; + 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */; }; + 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 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.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.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 */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; @@ -198,13 +203,19 @@ 8C8118722C220B5B00E6FC94 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8C8118712C220B5B00E6FC94 /* Yams */; }; 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; }; 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; }; + 8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */; }; + 8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */; }; + 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; }; 8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; }; 8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; }; 8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBC14852D357CDB00BBD901 /* StorageView.swift */; }; + 8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */; }; + 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; B70A39732D24090D00E80A5F /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A39722D24090D00E80A5F /* TagListView.swift */; }; + B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70CE9E52D4BE5930080F36D /* GroupMentions.swift */; }; B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; }; B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; @@ -224,7 +235,6 @@ CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; }; CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; }; - CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; }; CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; @@ -237,6 +247,8 @@ E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; }; + E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */; }; + E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -518,17 +530,22 @@ 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a"; sourceTree = ""; }; - 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a"; sourceTree = ""; }; - 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; + 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = ""; }; + 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportView.swift; sourceTree = ""; }; + 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryChatView.swift; sourceTree = ""; }; + 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportChatToolbar.swift; sourceTree = ""; }; + 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 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.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.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 = ""; }; 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; @@ -550,12 +567,18 @@ 8C852B072C1086D100BA61E8 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; + 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollHelpers.swift; sourceTree = ""; }; + 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = ""; }; + 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.swift; sourceTree = ""; }; 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = ""; }; 8CBC14852D357CDB00BBD901 /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; + 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndlessScrollView.swift; sourceTree = ""; }; + 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewCells.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; B70A39722D24090D00E80A5F /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = ""; }; + B70CE9E52D4BE5930080F36D /* GroupMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMentions.swift; sourceTree = ""; }; B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; @@ -574,7 +597,6 @@ CEE723D42C3C21F50009AE93 /* SimpleX SE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX SE.entitlements"; sourceTree = ""; }; CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = ""; }; - CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = ""; }; CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetRepresentable.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -633,6 +655,8 @@ E5DCF9A62C590731007928CC /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; E5DCF9A72C590732007928CC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = ""; }; + E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = ""; }; + E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -673,14 +697,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */, + 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, + 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, + 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */, - 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -735,6 +759,8 @@ 5C5F4AC227A5E9AF00B51EF1 /* Chat */ = { isa = PBXGroup; children = ( + 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */, + 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */, 6440CA01288AEC770062C672 /* Group */, 5CE4407427ADB657007B033A /* ChatItem */, 5CEACCE527DE977C000BD591 /* ComposeMessage */, @@ -745,11 +771,13 @@ 5CE4407127ADB1D0007B033A /* Emoji.swift */, 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */, 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */, - CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */, 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, + 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */, + 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */, + 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */, ); path = Chat; sourceTree = ""; @@ -757,11 +785,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 649B28D82CFE07CF00536B68 /* libffi.a */, - 649B28DC2CFE07CF00536B68 /* libgmp.a */, - 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */, + 64C829992D54AEEE006B9E89 /* libffi.a */, + 64C829982D54AEED006B9E89 /* libgmp.a */, + 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a */, ); path = Libraries; sourceTree = ""; @@ -781,6 +809,7 @@ 5C764E87279CBC8E000C6508 /* Model */ = { isa = PBXGroup; children = ( + E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */, 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, @@ -976,6 +1005,7 @@ isa = PBXGroup; children = ( 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, @@ -992,9 +1022,9 @@ 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, 5CDCAD7428188D2900503DA2 /* APITypes.swift */, 5C5E5D3C282447AB00B0488A /* CallTypes.swift */, + 5CDCAD7D2818941F00503DA2 /* API.swift */, CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */, 5C9FD96A27A56D4D0075386C /* JSON.swift */, - 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */, @@ -1056,6 +1086,7 @@ 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */, D72A9087294BD7A70047C86D /* NativeTextEditor.swift */, 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */, + 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -1096,6 +1127,11 @@ 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */, 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */, 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */, + B70CE9E52D4BE5930080F36D /* GroupMentions.swift */, + 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */, + 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */, + 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */, + 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, ); path = Group; sourceTree = ""; @@ -1405,13 +1441,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */, 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, + 8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */, 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, + 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, + 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */, 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */, @@ -1424,6 +1462,7 @@ 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, + 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, 8C74C3EC2C1B92A900039E77 /* Theme.swift in Sources */, @@ -1445,6 +1484,7 @@ 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, + 64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, @@ -1452,6 +1492,7 @@ B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, + B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */, CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */, 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, @@ -1486,6 +1527,7 @@ 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */, 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, + 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */, 8C74C3EA2C1B90AF00039E77 /* ThemeManager.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, @@ -1512,10 +1554,12 @@ 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */, + 8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */, 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */, 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */, + E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */, 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, @@ -1523,6 +1567,7 @@ 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, + 8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */, @@ -1561,7 +1606,9 @@ 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, + 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, + 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */, @@ -1586,6 +1633,7 @@ buildActionMask = 2147483647; files = ( 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, + E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1943,7 +1991,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1968,7 +2016,8 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; + OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1992,7 +2041,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2017,7 +2066,8 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; + OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2033,11 +2083,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2053,11 +2103,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2078,7 +2128,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2093,7 +2143,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2115,7 +2165,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2130,7 +2180,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2152,7 +2202,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2178,7 +2228,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2203,7 +2253,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2229,7 +2279,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2254,7 +2304,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2269,7 +2319,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2288,7 +2338,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2303,7 +2353,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.4; 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 987f7f3d41..0dd3483fd7 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -46,7 +46,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio var cConfirm = confirm.rawValue.cString(using: .utf8)! // the last parameter of chat_migrate_init is used to return the pointer to chat controller let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, backgroundMode ? 1 : 0, &chatController)! - let dbRes = dbMigrationResult(fromCString(cjson)) + let dbRes = dbMigrationResult(dataFromCString(cjson)) let encrypted = dbKey != "" let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey) let result = (encrypted, keychainErr ? .errorKeychain : dbRes) @@ -63,7 +63,7 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation var cKey = dbKey.cString(using: .utf8)! var cConfirm = confirmation.rawValue.cString(using: .utf8)! let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)! - return (dbMigrationResult(fromCString(cjson)), temporaryController) + return (dbMigrationResult(dataFromCString(cjson)), temporaryController) } public func chatInitControllerRemovingDatabases() { @@ -110,27 +110,42 @@ public func resetChatCtrl() { migrationResult = nil } -public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse { - var c = cmd.cmdString.cString(using: .utf8)! - let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)! - return chatResponse(fromCString(cjson)) +@inline(__always) +public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil) -> APIResult { + if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl) { + decodeAPIResult(d) + } else { + APIResult.error(.invalidJSON(json: nil)) + } +} + +@inline(__always) +public func sendSimpleXCmdStr(_ cmd: String, _ ctrl: chat_ctrl? = nil) -> Data? { + var c = cmd.cString(using: .utf8)! + return if let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c) { + dataFromCString(cjson) + } else { + nil + } } // in microseconds public let MESSAGE_TIMEOUT: Int32 = 15_000_000 -public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> ChatResponse? { - if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout) { - let s = fromCString(cjson) - return s == "" ? nil : chatResponse(s) +@inline(__always) +public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> APIResult? { + if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout), + let d = dataFromCString(cjson) { + decodeAPIResult(d) + } else { + nil } - return nil } public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_markdown(&c) { - if let d = fromCString(cjson).data(using: .utf8) { + if let d = dataFromCString(cjson) { do { let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) return r.formattedText @@ -154,7 +169,7 @@ struct ParsedMarkdown: Decodable { public func parseServerAddress(_ s: String) -> ServerAddress? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_server(&c) { - if let d = fromCString(cjson).data(using: .utf8) { + if let d = dataFromCString(cjson) { do { let r = try jsonDecoder.decode(ParsedServerAddress.self, from: d) return r.serverAddress @@ -171,67 +186,34 @@ struct ParsedServerAddress: Decodable { var parseError: String } +@inline(__always) public func fromCString(_ c: UnsafeMutablePointer) -> String { let s = String.init(cString: c) free(c) return s } -public func chatResponse(_ s: String) -> ChatResponse { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") +@inline(__always) +public func dataFromCString(_ c: UnsafeMutablePointer) -> Data? { + let len = strlen(c) + if len > 0 { + return Data(bytesNoCopy: c, count: len, deallocator: .free) + } else { + free(c) + return nil } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "apiChats" { - if let jApiChats = jResp["apiChats"] as? NSDictionary, - let user: UserRef = try? decodeObject(jApiChats["user"] as Any), - let jChats = jApiChats["chats"] as? NSArray { - let chats = jChats.map { jChat in - if let chatData = try? parseChatData(jChat) { - return chatData - } - return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "") - } - return .apiChats(user: user, chats: chats) - } - } else if type == "apiChat" { - if let jApiChat = jResp["apiChat"] as? NSDictionary, - let user: UserRef = try? decodeObject(jApiChat["user"] as Any), - let jChat = jApiChat["chat"] as? NSDictionary, - let chat = try? parseChatData(jChat) { - return .apiChat(user: user, chat: chat) - } - } else if type == "chatCmdError" { - if let jError = jResp["chatCmdError"] as? NSDictionary { - return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } else if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return ChatResponse.response(type: type ?? "invalid", json: json ?? s) } -private func decodeUser_(_ jDict: NSDictionary) -> UserRef? { +@inline(__always) +public func dataToString(_ d: Data?) -> String { + if let d { + String(data: d, encoding: .utf8) ?? "invalid string" + } else { + "no data" + } +} + +public func decodeUser_(_ jDict: NSDictionary) -> UserRef? { if let user_ = jDict["user_"] { try? decodeObject(user_ as Any) } else { @@ -239,7 +221,7 @@ private func decodeUser_(_ jDict: NSDictionary) -> UserRef? { } } -private func errorJson(_ jDict: NSDictionary) -> String? { +public func errorJson(_ jDict: NSDictionary) -> Data? { if let chatError = jDict["chatError"] { serializeJSON(chatError) } else { @@ -247,10 +229,15 @@ private func errorJson(_ jDict: NSDictionary) -> String? { } } -func parseChatData(_ jChat: Any) throws -> ChatData { +public func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) { let jChatDict = jChat as! NSDictionary let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!) let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!) + let navInfo: NavigationInfo = if let jNavInfo = jNavInfo as? NSDictionary, let jNav = jNavInfo["navInfo"] { + try decodeObject(jNav) + } else { + NavigationInfo() + } let jChatItems = jChatDict["chatItems"] as! NSArray let chatItems = jChatItems.map { jCI in if let ci: ChatItem = try? decodeObject(jCI) { @@ -259,16 +246,18 @@ func parseChatData(_ jChat: Any) throws -> ChatData { return ChatItem.invalidJSON( chatDir: decodeProperty(jCI, "chatDir"), meta: decodeProperty(jCI, "meta"), - json: serializeJSON(jCI, options: .prettyPrinted) ?? "" + json: serializeJSON(jCI, options: .prettyPrinted) ) } - return ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats) + return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo) } -func decodeObject(_ obj: Any) throws -> T { +@inline(__always) +public func decodeObject(_ obj: Any) throws -> T { try jsonDecoder.decode(T.self, from: JSONSerialization.data(withJSONObject: obj)) } +@inline(__always) func decodeProperty(_ obj: Any, _ prop: NSString) -> T? { if let jProp = (obj as? NSDictionary)?[prop] { return try? decodeObject(jProp) @@ -276,28 +265,52 @@ func decodeProperty(_ obj: Any, _ prop: NSString) -> T? { return nil } -func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> String? { - if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) { - return String(decoding: d, as: UTF8.self) +@inline(__always) +func getOWSF(_ obj: NSDictionary, _ prop: NSString) -> (type: String, object: NSDictionary)? { + if let j = obj[prop] as? NSDictionary, j.count == 1 || j.count == 2 { + var type = j.allKeys[0] as? String + if j.count == 2 && type == "_owsf" { + type = j.allKeys[1] as? String + } + if let type { + return (type, j) + } } return nil } -public func responseError(_ err: Error) -> String { - if let r = err as? ChatResponse { - switch r { - case let .chatCmdError(_, chatError): return chatErrorString(chatError) - case let .chatError(_, chatError): return chatErrorString(chatError) - default: return "\(String(describing: r.responseType)), details: \(String(describing: r.details))" - } +@inline(__always) +public func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> Data? { + if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) { + dataPrefix(d) } else { - return String(describing: err) + nil } } -func chatErrorString(_ err: ChatError) -> String { - if case let .invalidJSON(json) = err { return json } - return String(describing: err) +let MAX_JSON_VIEW_LENGTH = 2048 + +@inline(__always) +public func dataPrefix(_ d: Data) -> Data { + d.count > MAX_JSON_VIEW_LENGTH + ? Data(d.prefix(MAX_JSON_VIEW_LENGTH)) + : d +} + +public func responseError(_ err: Error) -> String { + if let e = err as? ChatError { + chatErrorString(e) + } else { + String(describing: err) + } +} + +public func chatErrorString(_ err: ChatError) -> String { + switch err { + case let .invalidJSON(json): dataToString(json) + case let .unexpectedResult(type): "unexpected result: \(type)" + default: String(describing: err) + } } public enum DBMigrationResult: Decodable, Equatable { @@ -336,15 +349,15 @@ public enum MTRError: Decodable, Equatable { case different(appMigration: String, dbMigration: String) } -func dbMigrationResult(_ s: String) -> DBMigrationResult { - let d = s.data(using: .utf8)! -// TODO is there a way to do it without copying the data? e.g: -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - return try jsonDecoder.decode(DBMigrationResult.self, from: d) - } catch let error { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - return .unknown(json: s) +func dbMigrationResult(_ d: Data?) -> DBMigrationResult { + if let d { + do { + return try jsonDecoder.decode(DBMigrationResult.self, from: d) + } catch let error { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + return .unknown(json: dataToString(d)) + } + } else { + return .unknown(json: "no data") } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4ae9bda0f2..b8d2361ac8 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -13,1687 +13,183 @@ import Network public let jsonDecoder = getJSONDecoder() public let jsonEncoder = getJSONEncoder() -public enum ChatCommand { - case showActiveUser - case createActiveUser(profile: Profile?, pastTimestamp: Bool) - case listUsers - case apiSetActiveUser(userId: Int64, viewPwd: String?) - case setAllContactReceipts(enable: Bool) - case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) - case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) - case apiHideUser(userId: Int64, viewPwd: String) - case apiUnhideUser(userId: Int64, viewPwd: String) - case apiMuteUser(userId: Int64) - case apiUnmuteUser(userId: Int64) - case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) - case startChat(mainApp: Bool, enableSndFiles: Bool) - case checkChatRunning - case apiStopChat - case apiActivateChat(restoreChat: Bool) - case apiSuspendChat(timeoutMicroseconds: Int) - case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) - case apiSetEncryptLocalFiles(enable: Bool) - case apiExportArchive(config: ArchiveConfig) - case apiImportArchive(config: ArchiveConfig) - case apiDeleteStorage - case apiStorageEncryption(config: DBEncryptionConfig) - case testStorageEncryption(key: String) - case apiSaveSettings(settings: AppSettings) - case apiGetSettings(settings: AppSettings) - case apiGetChatTags(userId: Int64) - case apiGetChats(userId: Int64) - case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) - case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) - case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) - case apiCreateChatTag(tag: ChatTagData) - case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) - case apiDeleteChatTag(tagId: Int64) - case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData) - case apiReorderChatTags(tagIds: [Int64]) - case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) - case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) - case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) - case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) - case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) - case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) - case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) - case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64]) - case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) - case apiGetNtfToken - case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) - case apiVerifyToken(token: DeviceToken, nonce: String, code: String) - case apiDeleteToken(token: DeviceToken) - case apiGetNtfConns(nonce: String, encNtfInfo: String) - case apiGetConnNtfMessages(connIds: [String]) - case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) - case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) - case apiJoinGroup(groupId: Int64) - case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) - case apiBlockMemberForAll(groupId: Int64, memberId: Int64, blocked: Bool) - case apiRemoveMember(groupId: Int64, memberId: Int64) - case apiLeaveGroup(groupId: Int64) - case apiListMembers(groupId: Int64) - case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) - case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole) - case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) - case apiDeleteGroupLink(groupId: Int64) - case apiGetGroupLink(groupId: Int64) - case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) - case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) - case apiTestProtoServer(userId: Int64, server: String) - case apiGetServerOperators - case apiSetServerOperators(operators: [ServerOperator]) - case apiGetUserServers(userId: Int64) - case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers]) - case apiValidateServers(userId: Int64, userServers: [UserOperatorServers]) - case apiGetUsageConditions - case apiSetConditionsNotified(conditionsId: Int64) - case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) - case apiSetChatItemTTL(userId: Int64, seconds: Int64) - case apiGetChatItemTTL(userId: Int64) - case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?) - case apiSetNetworkConfig(networkConfig: NetCfg) - case apiGetNetworkConfig - case apiSetNetworkInfo(networkInfo: UserNetworkInfo) - case reconnectAllServers - case reconnectServer(userId: Int64, smpServer: String) - case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) - case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) - case apiContactInfo(contactId: Int64) - case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) - case apiContactQueueInfo(contactId: Int64) - case apiGroupMemberQueueInfo(groupId: Int64, groupMemberId: Int64) - case apiSwitchContact(contactId: Int64) - case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64) - case apiAbortSwitchContact(contactId: Int64) - case apiAbortSwitchGroupMember(groupId: Int64, groupMemberId: Int64) - case apiSyncContactRatchet(contactId: Int64, force: Bool) - case apiSyncGroupMemberRatchet(groupId: Int64, groupMemberId: Int64, force: Bool) - case apiGetContactCode(contactId: Int64) - 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 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 apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) - case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode) - case apiClearChat(type: ChatType, id: Int64) - case apiListContacts(userId: Int64) - case apiUpdateProfile(userId: Int64, profile: Profile) - case apiSetContactPrefs(contactId: Int64, preferences: Preferences) - case apiSetContactAlias(contactId: Int64, localAlias: String) - case apiSetGroupAlias(groupId: Int64, localAlias: String) - case apiSetConnectionAlias(connId: Int64, localAlias: String) - case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) - case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) - case apiCreateMyAddress(userId: Int64) - case apiDeleteMyAddress(userId: Int64) - case apiShowMyAddress(userId: Int64) - case apiSetProfileAddress(userId: Int64, on: Bool) - case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) - case apiAcceptContact(incognito: Bool, contactReqId: Int64) - case apiRejectContact(contactReqId: Int64) - // WebRTC calls - case apiSendCallInvitation(contact: Contact, callType: CallType) - case apiRejectCall(contact: Contact) - case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer) - case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) - case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) - case apiEndCall(contact: Contact) - case apiGetCallInvitations - case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) - // WebRTC calls / - case apiGetNetworkStatuses - case apiChatRead(type: ChatType, id: Int64) - case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) - case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) - case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) - case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) - case cancelFile(fileId: Int64) - // remote desktop commands - case setLocalDeviceName(displayName: String) - case connectRemoteCtrl(xrcpInvitation: String) - case findKnownRemoteCtrl - case confirmRemoteCtrl(remoteCtrlId: Int64) - case verifyRemoteCtrlSession(sessionCode: String) - case listRemoteCtrls - case stopRemoteCtrl - case deleteRemoteCtrl(remoteCtrlId: Int64) - case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) - case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) - case apiStandaloneFileInfo(url: String) - // misc - case showVersion - case getAgentSubsTotal(userId: Int64) - case getAgentServersSummary(userId: Int64) - case resetAgentServersStats - case string(String) - - public var cmdString: String { - get { - switch self { - case .showActiveUser: return "/u" - case let .createActiveUser(profile, pastTimestamp): - let user = NewUser(profile: profile, pastTimestamp: pastTimestamp) - return "/_create user \(encodeJSON(user))" - case .listUsers: return "/users" - case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" - case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))" - case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings): - let umrs = userMsgReceiptSettings - return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" - case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings): - let umrs = userMsgReceiptSettings - return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" - case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" - case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" - case let .apiMuteUser(userId): return "/_mute user \(userId)" - case let .apiUnmuteUser(userId): return "/_unmute user \(userId)" - case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" - case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" - case .checkChatRunning: return "/_check running" - case .apiStopChat: return "/_stop" - case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" - case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" - case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" - case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" - case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" - case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" - case .apiDeleteStorage: return "/_db delete" - case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" - case let .testStorageEncryption(key): return "/db test key \(key)" - case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))" - case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))" - case let .apiGetChatTags(userId): return "/_get tags \(userId)" - case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" - case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + - (search == "" ? "" : " search=\(search)") - case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)" - case let .apiSendMessages(type, id, live, ttl, composedMessages): - let msgs = encodeJSON(composedMessages) - let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" - case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" - case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" - case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))" - case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiCreateChatItems(noteFolderId, composedMessages): - let msgs = encodeJSON(composedMessages) - return "/_create *\(noteFolderId) json \(msgs)" - case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): - return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" - case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" - case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" - case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" - case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" - case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl): - let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" - case .apiGetNtfToken: return "/_ntf get " - case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" - case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" - case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" - case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" - case let .apiGetConnNtfMessages(connIds): return "/_ntf conn messages \(connIds.joined(separator: ","))" - case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" - case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" - case let .apiJoinGroup(groupId): return "/_join #\(groupId)" - case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" - case let .apiBlockMemberForAll(groupId, memberId, blocked): return "/_block #\(groupId) \(memberId) blocked=\(onOff(blocked))" - case let .apiRemoveMember(groupId, memberId): return "/_remove #\(groupId) \(memberId)" - 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 .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)" - case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" - case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" - case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" - case .apiGetServerOperators: return "/_operators" - case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" - case let .apiGetUserServers(userId): return "/_servers \(userId)" - case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))" - case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))" - case .apiGetUsageConditions: return "/_conditions" - case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)" - case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" - case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" - case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" - case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))" - case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" - case .apiGetNetworkConfig: return "/network" - case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" - case .reconnectAllServers: return "/reconnect" - case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" - case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" - case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" - case let .apiContactInfo(contactId): return "/_info @\(contactId)" - case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" - case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)" - case let .apiGroupMemberQueueInfo(groupId, groupMemberId): return "/_queue info #\(groupId) \(groupMemberId)" - case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" - case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)" - case let .apiAbortSwitchContact(contactId): return "/_abort switch @\(contactId)" - case let .apiAbortSwitchGroupMember(groupId, groupMemberId): return "/_abort switch #\(groupId) \(groupMemberId)" - case let .apiSyncContactRatchet(contactId, force): if force { - return "/_sync @\(contactId) force=on" - } else { - return "/_sync @\(contactId)" - } - case let .apiSyncGroupMemberRatchet(groupId, groupMemberId, force): if force { - return "/_sync #\(groupId) \(groupMemberId) force=on" - } else { - return "/_sync #\(groupId) \(groupMemberId)" - } - case let .apiGetContactCode(contactId): return "/_get code @\(contactId)" - case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)" - case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)" - 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 .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 .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))" - case let .apiListContacts(userId): return "/_contacts \(userId)" - case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" - case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" - case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" - case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))" - 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 .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))" - case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" - case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" - case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" - case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" - case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" - case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))" - case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" - case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" - case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" - case .apiGetCallInvitations: return "/_call get" - case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" - case .apiGetNetworkStatuses: return "/_network_statuses" - case let .apiChatRead(type, id): return "/_read chat \(ref(type, id))" - case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))" - case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" - case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" - case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))" - case let .cancelFile(fileId): return "/fcancel \(fileId)" - case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" - case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)" - case .findKnownRemoteCtrl: return "/find remote ctrl" - case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)" - case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)" - case .listRemoteCtrls: return "/list remote ctrls" - case .stopRemoteCtrl: return "/stop remote ctrl" - case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" - case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" - case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" - case let .apiStandaloneFileInfo(link): return "/_download info \(link)" - case .showVersion: return "/version" - case let .getAgentSubsTotal(userId): return "/get subs total \(userId)" - case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" - case .resetAgentServersStats: return "/reset servers stats" - case let .string(str): return str - } - } - } - - public var cmdType: String { - get { - switch self { - case .showActiveUser: return "showActiveUser" - case .createActiveUser: return "createActiveUser" - case .listUsers: return "listUsers" - case .apiSetActiveUser: return "apiSetActiveUser" - case .setAllContactReceipts: return "setAllContactReceipts" - case .apiSetUserContactReceipts: return "apiSetUserContactReceipts" - case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts" - case .apiHideUser: return "apiHideUser" - case .apiUnhideUser: return "apiUnhideUser" - case .apiMuteUser: return "apiMuteUser" - case .apiUnmuteUser: return "apiUnmuteUser" - case .apiDeleteUser: return "apiDeleteUser" - case .startChat: return "startChat" - case .checkChatRunning: return "checkChatRunning" - case .apiStopChat: return "apiStopChat" - case .apiActivateChat: return "apiActivateChat" - case .apiSuspendChat: return "apiSuspendChat" - case .apiSetAppFilePaths: return "apiSetAppFilePaths" - case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" - case .apiExportArchive: return "apiExportArchive" - case .apiImportArchive: return "apiImportArchive" - case .apiDeleteStorage: return "apiDeleteStorage" - case .apiStorageEncryption: return "apiStorageEncryption" - case .testStorageEncryption: return "testStorageEncryption" - case .apiSaveSettings: return "apiSaveSettings" - case .apiGetSettings: return "apiGetSettings" - case .apiGetChatTags: return "apiGetChatTags" - case .apiGetChats: return "apiGetChats" - case .apiGetChat: return "apiGetChat" - case .apiGetChatItemInfo: return "apiGetChatItemInfo" - case .apiSendMessages: return "apiSendMessages" - case .apiCreateChatTag: return "apiCreateChatTag" - case .apiSetChatTags: return "apiSetChatTags" - case .apiDeleteChatTag: return "apiDeleteChatTag" - case .apiUpdateChatTag: return "apiUpdateChatTag" - case .apiReorderChatTags: return "apiReorderChatTags" - case .apiCreateChatItems: return "apiCreateChatItems" - case .apiReportMessage: return "apiReportMessage" - case .apiUpdateChatItem: return "apiUpdateChatItem" - case .apiDeleteChatItem: return "apiDeleteChatItem" - case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" - case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" - case .apiChatItemReaction: return "apiChatItemReaction" - case .apiGetReactionMembers: return "apiGetReactionMembers" - case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" - case .apiForwardChatItems: return "apiForwardChatItems" - case .apiGetNtfToken: return "apiGetNtfToken" - case .apiRegisterToken: return "apiRegisterToken" - case .apiVerifyToken: return "apiVerifyToken" - case .apiDeleteToken: return "apiDeleteToken" - case .apiGetNtfConns: return "apiGetNtfConns" - case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" - case .apiNewGroup: return "apiNewGroup" - case .apiAddMember: return "apiAddMember" - case .apiJoinGroup: return "apiJoinGroup" - case .apiMemberRole: return "apiMemberRole" - case .apiBlockMemberForAll: return "apiBlockMemberForAll" - case .apiRemoveMember: return "apiRemoveMember" - case .apiLeaveGroup: return "apiLeaveGroup" - case .apiListMembers: return "apiListMembers" - case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" - case .apiCreateGroupLink: return "apiCreateGroupLink" - case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" - case .apiDeleteGroupLink: return "apiDeleteGroupLink" - case .apiGetGroupLink: return "apiGetGroupLink" - case .apiCreateMemberContact: return "apiCreateMemberContact" - case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" - case .apiTestProtoServer: return "apiTestProtoServer" - case .apiGetServerOperators: return "apiGetServerOperators" - case .apiSetServerOperators: return "apiSetServerOperators" - case .apiGetUserServers: return "apiGetUserServers" - case .apiSetUserServers: return "apiSetUserServers" - case .apiValidateServers: return "apiValidateServers" - case .apiGetUsageConditions: return "apiGetUsageConditions" - case .apiSetConditionsNotified: return "apiSetConditionsNotified" - case .apiAcceptConditions: return "apiAcceptConditions" - case .apiSetChatItemTTL: return "apiSetChatItemTTL" - case .apiGetChatItemTTL: return "apiGetChatItemTTL" - case .apiSetChatTTL: return "apiSetChatTTL" - case .apiSetNetworkConfig: return "apiSetNetworkConfig" - case .apiGetNetworkConfig: return "apiGetNetworkConfig" - case .apiSetNetworkInfo: return "apiSetNetworkInfo" - case .reconnectAllServers: return "reconnectAllServers" - case .reconnectServer: return "reconnectServer" - case .apiSetChatSettings: return "apiSetChatSettings" - case .apiSetMemberSettings: return "apiSetMemberSettings" - case .apiContactInfo: return "apiContactInfo" - case .apiGroupMemberInfo: return "apiGroupMemberInfo" - case .apiContactQueueInfo: return "apiContactQueueInfo" - case .apiGroupMemberQueueInfo: return "apiGroupMemberQueueInfo" - case .apiSwitchContact: return "apiSwitchContact" - case .apiSwitchGroupMember: return "apiSwitchGroupMember" - case .apiAbortSwitchContact: return "apiAbortSwitchContact" - case .apiAbortSwitchGroupMember: return "apiAbortSwitchGroupMember" - case .apiSyncContactRatchet: return "apiSyncContactRatchet" - case .apiSyncGroupMemberRatchet: return "apiSyncGroupMemberRatchet" - case .apiGetContactCode: return "apiGetContactCode" - case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" - case .apiVerifyContact: return "apiVerifyContact" - case .apiVerifyGroupMember: return "apiVerifyGroupMember" - case .apiAddContact: return "apiAddContact" - case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" - case .apiChangeConnectionUser: return "apiChangeConnectionUser" - case .apiConnectPlan: return "apiConnectPlan" - case .apiConnect: return "apiConnect" - case .apiDeleteChat: return "apiDeleteChat" - case .apiClearChat: return "apiClearChat" - case .apiListContacts: return "apiListContacts" - case .apiUpdateProfile: return "apiUpdateProfile" - case .apiSetContactPrefs: return "apiSetContactPrefs" - case .apiSetContactAlias: return "apiSetContactAlias" - case .apiSetGroupAlias: return "apiSetGroupAlias" - case .apiSetConnectionAlias: return "apiSetConnectionAlias" - case .apiSetUserUIThemes: return "apiSetUserUIThemes" - case .apiSetChatUIThemes: return "apiSetChatUIThemes" - case .apiCreateMyAddress: return "apiCreateMyAddress" - case .apiDeleteMyAddress: return "apiDeleteMyAddress" - case .apiShowMyAddress: return "apiShowMyAddress" - case .apiSetProfileAddress: return "apiSetProfileAddress" - case .apiAddressAutoAccept: return "apiAddressAutoAccept" - case .apiAcceptContact: return "apiAcceptContact" - case .apiRejectContact: return "apiRejectContact" - case .apiSendCallInvitation: return "apiSendCallInvitation" - case .apiRejectCall: return "apiRejectCall" - case .apiSendCallOffer: return "apiSendCallOffer" - case .apiSendCallAnswer: return "apiSendCallAnswer" - case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" - case .apiEndCall: return "apiEndCall" - case .apiGetCallInvitations: return "apiGetCallInvitations" - case .apiCallStatus: return "apiCallStatus" - case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" - case .apiChatRead: return "apiChatRead" - case .apiChatItemsRead: return "apiChatItemsRead" - case .apiChatUnread: return "apiChatUnread" - case .receiveFile: return "receiveFile" - case .setFileToReceive: return "setFileToReceive" - case .cancelFile: return "cancelFile" - case .setLocalDeviceName: return "setLocalDeviceName" - case .connectRemoteCtrl: return "connectRemoteCtrl" - case .findKnownRemoteCtrl: return "findKnownRemoteCtrl" - case .confirmRemoteCtrl: return "confirmRemoteCtrl" - case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession" - case .listRemoteCtrls: return "listRemoteCtrls" - case .stopRemoteCtrl: return "stopRemoteCtrl" - case .deleteRemoteCtrl: return "deleteRemoteCtrl" - case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" - case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" - case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" - case .showVersion: return "showVersion" - case .getAgentSubsTotal: return "getAgentSubsTotal" - case .getAgentServersSummary: return "getAgentServersSummary" - case .resetAgentServersStats: return "resetAgentServersStats" - case .string: return "console command" - } - } - } - - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" - } - - func joinedIds(_ ids: [Int64]) -> String { - ids.map { "\($0)" }.joined(separator: ",") - } - - func chatItemTTLStr(seconds: Int64?) -> String { - if let seconds = seconds { - return String(seconds) - } else { - return "default" - } - } - - public var obfuscated: ChatCommand { - switch self { - case let .apiStorageEncryption(cfg): - return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey))) - case let .apiSetActiveUser(userId, viewPwd): - return .apiSetActiveUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiHideUser(userId, viewPwd): - return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiUnhideUser(userId, viewPwd): - return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiDeleteUser(userId, delSMPQueues, viewPwd): - return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) - case let .testStorageEncryption(key): - return .testStorageEncryption(key: obfuscate(key)) - default: return self - } - } - - private func obfuscate(_ s: String) -> String { - s == "" ? "" : "***" - } - - private func obfuscate(_ s: String?) -> String? { - if let s = s { - return obfuscate(s) - } - return nil - } - - private func onOffParam(_ param: String, _ b: Bool?) -> String { - if let b = b { - return " \(param)=\(onOff(b))" - } - return "" - } - - private func maybePwd(_ pwd: String?) -> String { - pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) - } +public protocol ChatCmdProtocol { + var cmdString: String { get } } -private func onOff(_ b: Bool) -> String { +@inline(__always) +public func onOff(_ b: Bool) -> String { b ? "on" : "off" } -public struct APIResponse: Decodable { - var resp: ChatResponse -} - -public enum ChatResponse: Decodable, Error { - case response(type: String, json: String) - case activeUser(user: User) - case usersList(users: [UserInfo]) - case chatStarted - case chatRunning - case chatStopped - case chatSuspended - case apiChats(user: UserRef, chats: [ChatData]) - case apiChat(user: UserRef, chat: ChatData) - case chatTags(user: UserRef, userTags: [ChatTag]) - case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) - case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) - case serverOperatorConditions(conditions: ServerOperatorConditions) - case userServers(user: UserRef, userServers: [UserOperatorServers]) - case userServersValidation(user: UserRef, serverErrors: [UserServersError]) - case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) - case chatItemTTL(user: UserRef, chatItemTTL: Int64?) - case networkConfig(networkConfig: NetCfg) - case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) - case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) - case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo) - case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) - case groupMemberSwitchStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactSwitchAborted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) - case groupMemberSwitchAborted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) - case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) - case contactRatchetSyncStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) - case groupMemberRatchetSyncStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactRatchetSync(user: UserRef, contact: Contact, ratchetSyncProgress: RatchetSyncProgress) - case groupMemberRatchetSync(user: UserRef, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress) - case contactVerificationReset(user: UserRef, contact: Contact) - case groupMemberVerificationReset(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case contactCode(user: UserRef, contact: Contact, connectionCode: String) - 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 connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) - case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) - case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) - case sentConfirmation(user: UserRef, connection: PendingContactConnection) - case sentInvitation(user: UserRef, connection: PendingContactConnection) - case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) - case contactAlreadyExists(user: UserRef, contact: Contact) - case contactDeleted(user: UserRef, contact: Contact) - case contactDeletedByContact(user: UserRef, contact: Contact) - case chatCleared(user: UserRef, chatInfo: ChatInfo) - case userProfileNoChange(user: User) - case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) - case userPrivacy(user: User, updatedUser: User) - case contactAliasUpdated(user: UserRef, toContact: Contact) - case groupAliasUpdated(user: UserRef, toGroup: GroupInfo) - case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection) - 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 userContactLinkDeleted(user: User) - case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) - case contactConnecting(user: UserRef, contact: Contact) - case contactSndReady(user: UserRef, contact: Contact) - case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) - case acceptingContactRequest(user: UserRef, contact: Contact) - case contactRequestRejected(user: UserRef) - case contactUpdated(user: UserRef, toContact: Contact) - case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) - case networkStatus(networkStatus: NetworkStatus, connections: [String]) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) - case groupSubscribed(user: UserRef, groupInfo: GroupRef) - case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) - case groupEmpty(user: UserRef, groupInfo: GroupInfo) - case userContactLinkSubscribed - case newChatItems(user: UserRef, chatItems: [AChatItem]) - case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) - case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) - case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) - case chatItemUpdated(user: UserRef, chatItem: AChatItem) - case chatItemNotChanged(user: UserRef, chatItem: AChatItem) - case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) - case reactionMembers(user: UserRef, memberReactions: [MemberReaction]) - case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) - case contactsList(user: UserRef, contacts: [Contact]) - // group events - case groupCreated(user: UserRef, groupInfo: GroupInfo) - case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) - case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) - case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) - case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) - case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case leftMemberUser(user: UserRef, groupInfo: GroupInfo) - case groupMembers(user: UserRef, group: Group) - case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) - case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) - case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) - case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case memberRoleUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool) - case memberBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, blocked: Bool) - case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) - case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) - case groupInvitation(user: UserRef, groupInfo: GroupInfo) // unused - case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) - case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - 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 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) - case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) - // receiving file events - case rcvFileAccepted(user: UserRef, chatItem: AChatItem) - case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) - case standaloneFileInfo(fileMeta: MigrationFileLinkData?) - case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats - case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) - case rcvFileComplete(user: UserRef, chatItem: AChatItem) - case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) - case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) - case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) - case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) - case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) - // sending file events - case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) - case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) - case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload - case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used - case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) - case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) - case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) - case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) - case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) - case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - // call events - case callInvitation(callInvitation: RcvCallInvitation) - case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) - case callAnswer(user: UserRef, contact: Contact, answer: WebRTCSession) - case callExtraInfo(user: UserRef, contact: Contact, extraInfo: WebRTCExtraInfo) - case callEnded(user: UserRef, contact: Contact) - case callInvitations(callInvitations: [RcvCallInvitation]) - case ntfTokenStatus(status: NtfTknStatus) - case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) - case ntfConns(ntfConns: [NtfConn]) - case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) - case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) - case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) - case contactDisabled(user: UserRef, contact: Contact) - // remote desktop responses/events - case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) - case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) - case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) - case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) - // pq - case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) - // misc - case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) - case cmdOk(user: UserRef?) - case agentSubsTotal(user: UserRef, subsTotal: SMPServerSubs, hasSession: Bool) - case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary) - case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) - case chatCmdError(user_: UserRef?, chatError: ChatError) - case chatError(user_: UserRef?, chatError: ChatError) - case archiveExported(archiveErrors: [ArchiveError]) - case archiveImported(archiveErrors: [ArchiveError]) - case appSettings(appSettings: AppSettings) - - public var responseType: String { - get { - switch self { - case let .response(type, _): return "* \(type)" - case .activeUser: return "activeUser" - case .usersList: return "usersList" - case .chatStarted: return "chatStarted" - case .chatRunning: return "chatRunning" - case .chatStopped: return "chatStopped" - case .chatSuspended: return "chatSuspended" - case .apiChats: return "apiChats" - case .apiChat: return "apiChat" - case .chatTags: return "chatTags" - case .chatItemInfo: return "chatItemInfo" - case .serverTestResult: return "serverTestResult" - case .serverOperatorConditions: return "serverOperators" - case .userServers: return "userServers" - case .userServersValidation: return "userServersValidation" - case .usageConditions: return "usageConditions" - case .chatItemTTL: return "chatItemTTL" - case .networkConfig: return "networkConfig" - case .contactInfo: return "contactInfo" - case .groupMemberInfo: return "groupMemberInfo" - case .queueInfo: return "queueInfo" - case .contactSwitchStarted: return "contactSwitchStarted" - case .groupMemberSwitchStarted: return "groupMemberSwitchStarted" - case .contactSwitchAborted: return "contactSwitchAborted" - case .groupMemberSwitchAborted: return "groupMemberSwitchAborted" - case .contactSwitch: return "contactSwitch" - case .groupMemberSwitch: return "groupMemberSwitch" - case .contactRatchetSyncStarted: return "contactRatchetSyncStarted" - case .groupMemberRatchetSyncStarted: return "groupMemberRatchetSyncStarted" - case .contactRatchetSync: return "contactRatchetSync" - case .groupMemberRatchetSync: return "groupMemberRatchetSync" - case .contactVerificationReset: return "contactVerificationReset" - case .groupMemberVerificationReset: return "groupMemberVerificationReset" - case .contactCode: return "contactCode" - case .groupMemberCode: return "groupMemberCode" - case .connectionVerified: return "connectionVerified" - case .tagsUpdated: return "tagsUpdated" - case .invitation: return "invitation" - case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" - case .connectionUserChanged: return "connectionUserChanged" - case .connectionPlan: return "connectionPlan" - case .sentConfirmation: return "sentConfirmation" - case .sentInvitation: return "sentInvitation" - case .sentInvitationToContact: return "sentInvitationToContact" - case .contactAlreadyExists: return "contactAlreadyExists" - case .contactDeleted: return "contactDeleted" - case .contactDeletedByContact: return "contactDeletedByContact" - case .chatCleared: return "chatCleared" - case .userProfileNoChange: return "userProfileNoChange" - case .userProfileUpdated: return "userProfileUpdated" - case .userPrivacy: return "userPrivacy" - case .contactAliasUpdated: return "contactAliasUpdated" - case .groupAliasUpdated: return "groupAliasUpdated" - case .connectionAliasUpdated: return "connectionAliasUpdated" - case .contactPrefsUpdated: return "contactPrefsUpdated" - case .userContactLink: return "userContactLink" - case .userContactLinkUpdated: return "userContactLinkUpdated" - case .userContactLinkCreated: return "userContactLinkCreated" - case .userContactLinkDeleted: return "userContactLinkDeleted" - case .contactConnected: return "contactConnected" - case .contactConnecting: return "contactConnecting" - case .contactSndReady: return "contactSndReady" - case .receivedContactRequest: return "receivedContactRequest" - case .acceptingContactRequest: return "acceptingContactRequest" - case .contactRequestRejected: return "contactRequestRejected" - case .contactUpdated: return "contactUpdated" - case .groupMemberUpdated: return "groupMemberUpdated" - case .networkStatus: return "networkStatus" - case .networkStatuses: return "networkStatuses" - case .groupSubscribed: return "groupSubscribed" - case .memberSubErrors: return "memberSubErrors" - case .groupEmpty: return "groupEmpty" - case .userContactLinkSubscribed: return "userContactLinkSubscribed" - case .newChatItems: return "newChatItems" - case .groupChatItemsDeleted: return "groupChatItemsDeleted" - case .forwardPlan: return "forwardPlan" - case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" - case .chatItemUpdated: return "chatItemUpdated" - case .chatItemNotChanged: return "chatItemNotChanged" - case .chatItemReaction: return "chatItemReaction" - case .reactionMembers: return "reactionMembers" - case .chatItemsDeleted: return "chatItemsDeleted" - case .contactsList: return "contactsList" - case .groupCreated: return "groupCreated" - case .sentGroupInvitation: return "sentGroupInvitation" - case .userAcceptedGroupSent: return "userAcceptedGroupSent" - case .groupLinkConnecting: return "groupLinkConnecting" - case .businessLinkConnecting: return "businessLinkConnecting" - case .userDeletedMember: return "userDeletedMember" - case .leftMemberUser: return "leftMemberUser" - case .groupMembers: return "groupMembers" - case .receivedGroupInvitation: return "receivedGroupInvitation" - case .groupDeletedUser: return "groupDeletedUser" - case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" - case .memberRole: return "memberRole" - case .memberRoleUser: return "memberRoleUser" - case .memberBlockedForAll: return "memberBlockedForAll" - case .memberBlockedForAllUser: return "memberBlockedForAllUser" - case .deletedMemberUser: return "deletedMemberUser" - case .deletedMember: return "deletedMember" - case .leftMember: return "leftMember" - case .groupDeleted: return "groupDeleted" - case .contactsMerged: return "contactsMerged" - case .groupInvitation: return "groupInvitation" - case .userJoinedGroup: return "userJoinedGroup" - case .joinedGroupMember: return "joinedGroupMember" - case .connectedToGroupMember: return "connectedToGroupMember" - case .groupRemoved: return "groupRemoved" - case .groupUpdated: return "groupUpdated" - case .groupLinkCreated: return "groupLinkCreated" - case .groupLink: return "groupLink" - case .groupLinkDeleted: return "groupLinkDeleted" - case .newMemberContact: return "newMemberContact" - case .newMemberContactSentInv: return "newMemberContactSentInv" - case .newMemberContactReceivedInv: return "newMemberContactReceivedInv" - case .rcvFileAccepted: return "rcvFileAccepted" - case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" - case .standaloneFileInfo: return "standaloneFileInfo" - case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated" - case .rcvFileStart: return "rcvFileStart" - case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" - case .rcvFileComplete: return "rcvFileComplete" - case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete" - case .rcvFileCancelled: return "rcvFileCancelled" - case .rcvFileSndCancelled: return "rcvFileSndCancelled" - case .rcvFileError: return "rcvFileError" - case .rcvFileWarning: return "rcvFileWarning" - case .sndFileStart: return "sndFileStart" - case .sndFileComplete: return "sndFileComplete" - case .sndFileCancelled: return "sndFileCancelled" - case .sndStandaloneFileCreated: return "sndStandaloneFileCreated" - case .sndFileStartXFTP: return "sndFileStartXFTP" - case .sndFileProgressXFTP: return "sndFileProgressXFTP" - case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP" - case .sndFileRcvCancelled: return "sndFileRcvCancelled" - case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" - case .sndStandaloneFileComplete: return "sndStandaloneFileComplete" - case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" - case .sndFileError: return "sndFileError" - case .sndFileWarning: return "sndFileWarning" - case .callInvitation: return "callInvitation" - case .callOffer: return "callOffer" - case .callAnswer: return "callAnswer" - case .callExtraInfo: return "callExtraInfo" - case .callEnded: return "callEnded" - case .callInvitations: return "callInvitations" - case .ntfTokenStatus: return "ntfTokenStatus" - case .ntfToken: return "ntfToken" - case .ntfConns: return "ntfConns" - case .connNtfMessages: return "connNtfMessages" - case .ntfMessage: return "ntfMessage" - case .contactConnectionDeleted: return "contactConnectionDeleted" - case .contactDisabled: return "contactDisabled" - case .remoteCtrlList: return "remoteCtrlList" - case .remoteCtrlFound: return "remoteCtrlFound" - case .remoteCtrlConnecting: return "remoteCtrlConnecting" - case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" - case .remoteCtrlConnected: return "remoteCtrlConnected" - case .remoteCtrlStopped: return "remoteCtrlStopped" - case .contactPQEnabled: return "contactPQEnabled" - case .versionInfo: return "versionInfo" - case .cmdOk: return "cmdOk" - case .agentSubsTotal: return "agentSubsTotal" - case .agentServersSummary: return "agentServersSummary" - case .agentSubsSummary: return "agentSubsSummary" - case .chatCmdError: return "chatCmdError" - case .chatError: return "chatError" - case .archiveExported: return "archiveExported" - case .archiveImported: return "archiveImported" - case .appSettings: return "appSettings" - } - } - } - - public var details: String { - get { - switch self { - case let .response(_, json): return json - case let .activeUser(user): return String(describing: user) - case let .usersList(users): return String(describing: users) - case .chatStarted: return noDetails - case .chatRunning: return noDetails - case .chatStopped: return noDetails - case .chatSuspended: return noDetails - case let .apiChats(u, chats): return withUser(u, String(describing: chats)) - case let .apiChat(u, chat): return withUser(u, String(describing: chat)) - case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") - case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") - case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") - case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" - case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") - case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") - case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" - case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) - case let .networkConfig(networkConfig): return String(describing: networkConfig) - case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") - case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") - case let .queueInfo(u, rcvMsgInfo, queueInfo): - let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } - return withUser(u, "rcvMsgInfo: \(msgInfo)\nqueueInfo: \(encodeJSON(queueInfo))") - case let .contactSwitchStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") - case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") - case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") - case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") - case let .contactVerificationReset(u, contact): return withUser(u, "contact: \(String(describing: contact))") - case let .groupMemberVerificationReset(u, groupInfo, member): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))") - case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") - 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 .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 .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)) - case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) - case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) - case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) - case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) - case .userProfileNoChange: return noDetails - case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) - case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) - case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) - case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) - 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 .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)) - case let .contactSndReady(u, contact): return withUser(u, String(describing: contact)) - case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) - case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) - case .contactRequestRejected: return noDetails - case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) - case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") - case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) - case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) - case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case .userContactLinkSubscribed: return noDetails - case let .newChatItems(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) - case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): - return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") - case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") - case let .chatItemsStatusesUpdated(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) - case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") - case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))") - case let .chatItemsDeleted(u, items, byUser): - let itemsString = items.map { item in - "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") - return withUser(u, itemsString + "\nbyUser: \(byUser)") - case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) - case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") - case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") - case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") - case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") - case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .groupMembers(u, group): return withUser(u, String(describing: group)) - case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") - case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") - case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") - case let .memberRoleUser(u, groupInfo, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") - case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)") - case let .memberBlockedForAllUser(u, groupInfo, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nblocked: \(blocked)") - case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)") - case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") - case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - 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 .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)") - case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") - case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) - case .rcvFileAcceptedSndCancelled: return noDetails - case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) - case .rcvStandaloneFileCreated: return noDetails - case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") - case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) - case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") - case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") - case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) - case .sndStandaloneFileCreated: return noDetails - case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") - case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) - case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) - case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .callInvitation(inv): return String(describing: inv) - case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") - case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") - case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") - case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") - case let .callInvitations(invs): return String(describing: invs) - case let .ntfTokenStatus(status): return String(describing: status) - case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" - case let .ntfConns(ntfConns): return String(describing: ntfConns) - case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" - case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") - case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) - case let .contactDisabled(u, contact): return withUser(u, String(describing: contact)) - case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" - case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" - case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" - case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) - case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" - case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") - case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" - case .cmdOk: return noDetails - case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)") - case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) - case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) - case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) - case let .archiveExported(archiveErrors): return String(describing: archiveErrors) - case let .archiveImported(archiveErrors): return String(describing: archiveErrors) - case let .appSettings(appSettings): return String(describing: appSettings) - } - } - } - - private var noDetails: String { get { "\(responseType): no details" } } - - private func withUser(_ u: (any UserLike)?, _ s: String) -> String { - if let id = u?.userId { - return "userId: \(id)\n\(s)" - } - return s - } -} - -public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { - switch chatResponse { - case let .chatCmdError(_, .error(error)): return error - case let .chatError(_, .error(error)): return error - default: return nil - } -} - -public enum ChatDeleteMode: Codable { - case full(notify: Bool) - case entity(notify: Bool) - case messages - - var cmdString: String { - switch self { - case let .full(notify): "full notify=\(onOff(notify))" - case let .entity(notify): "entity notify=\(onOff(notify))" - case .messages: "messages" - } - } - - public var isEntity: Bool { - switch self { - case .entity: return true - default: return false - } - } -} - -public enum ConnectionPlan: Decodable, Hashable { - case invitationLink(invitationLinkPlan: InvitationLinkPlan) - case contactAddress(contactAddressPlan: ContactAddressPlan) - case groupLink(groupLinkPlan: GroupLinkPlan) -} - -public enum InvitationLinkPlan: Decodable, Hashable { - case ok - case ownLink - case connecting(contact_: Contact?) - case known(contact: Contact) -} - -public enum ContactAddressPlan: Decodable, Hashable { - case ok - case ownLink - case connectingConfirmReconnect - case connectingProhibit(contact: Contact) - case known(contact: Contact) - case contactViaAddress(contact: Contact) -} - -public enum GroupLinkPlan: Decodable, Hashable { - case ok - case ownLink(groupInfo: GroupInfo) - case connectingConfirmReconnect - case connectingProhibit(groupInfo_: GroupInfo?) - case known(groupInfo: GroupInfo) -} - -struct NewUser: Encodable { - var profile: Profile? - var pastTimestamp: Bool -} - -public enum ChatPagination { - case last(count: Int) - case after(chatItemId: Int64, count: Int) - case before(chatItemId: Int64, count: Int) - case around(chatItemId: Int64, count: Int) - - var cmdString: String { - switch self { - case let .last(count): return "count=\(count)" - case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" - case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" - case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" - } - } -} - -public struct ChatTagData: Encodable { - public var emoji: String? - public var text: String +public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { + case result(R) + case error(ChatError) + case invalid(type: String, json: Data) - public init(emoji: String?, text: String) { - self.emoji = emoji - self.text = text + public var responseType: String { + switch self { + case let .result(r): r.responseType + case let .error(e): "error \(e.errorType)" + case let .invalid(type, _): "* \(type)" + } } + + public var unexpected: ChatError { + switch self { + case let .result(r): .unexpectedResult(type: r.responseType) + case let .error(e): e + case let .invalid(type, _): .unexpectedResult(type: "* \(type)") + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.result) { + let result = try container.decode(R.self, forKey: .result) + self = .result(result) + } else { + let error = try container.decode(ChatError.self, forKey: .error) + self = .error(error) + } + } + + private enum CodingKeys: String, CodingKey { + case result, error + } +} + +public protocol ChatAPIResult: Decodable { + var responseType: String { get } + var details: String { get } + static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? +} + +extension ChatAPIResult { + public var noDetails: String { "\(self.responseType): no details" } + + @inline(__always) + public static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? { + nil + } + + @inline(__always) + public var unexpected: ChatError { + .unexpectedResult(type: self.responseType) + } +} + +public func decodeAPIResult(_ d: Data) -> APIResult { +// print("decodeAPIResult \(String(describing: R.self))") + do { +// return try withStackSizeLimit { try jsonDecoder.decode(APIResult.self, from: d) } + return try jsonDecoder.decode(APIResult.self, from: d) + } catch {} + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let (_, jErr) = getOWSF(j, "error") { + return APIResult.error(.invalidJSON(json: errorJson(jErr))) as APIResult + } else if let (type, jRes) = getOWSF(j, "result") { + return if let r = R.fallbackResult(type, jRes) { + APIResult.result(r) + } else { + APIResult.invalid(type: type, json: dataPrefix(d)) + } + } + } + return APIResult.invalid(type: "invalid", json: dataPrefix(d)) +} + +// Default stack size for the main thread is 1mb, for secondary threads - 512 kb. +// This function can be used to test what size is used (or to increase available stack size). +// Stack size must be a multiple of system page size (16kb). +//private let stackSizeLimit: Int = 256 * 1024 +// +//private func withStackSizeLimit(_ f: @escaping () throws -> T) throws -> T { +// let semaphore = DispatchSemaphore(value: 0) +// var result: Result? +// let thread = Thread { +// do { +// result = .success(try f()) +// } catch { +// result = .failure(error) +// } +// semaphore.signal() +// } +// +// thread.stackSize = stackSizeLimit +// thread.qualityOfService = Thread.current.qualityOfService +// thread.start() +// +// semaphore.wait() +// +// switch result! { +// case let .success(r): return r +// case let .failure(e): throw e +// } +//} + +public func parseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [ChatData])? { + if let jApiChats = jResp["apiChats"] as? NSDictionary, + let user: UserRef = try? decodeObject(jApiChats["user"] as Any), + let jChats = jApiChats["chats"] as? NSArray { + let chats = jChats.map { jChat in + if let chatData = try? parseChatData(jChat) { + return chatData.0 + } + return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted)) + } + return (user, chats) + } else { + return nil + } +} + +public func withUser(_ u: (any UserLike)?, _ s: String) -> String { + if let id = u?.userId { + return "userId: \(id)\n\(s)" + } + return s +} + +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 struct ComposedMessage: Encodable { public var fileSource: CryptoFile? var quotedItemId: Int64? public var msgContent: MsgContent + public var mentions: [String: Int64] - public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) { + public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent, mentions: [String: Int64] = [:]) { self.fileSource = fileSource self.quotedItemId = quotedItemId self.msgContent = msgContent + self.mentions = mentions } } -public struct ArchiveConfig: Encodable { - var archivePath: String - var disableCompression: Bool? - - public init(archivePath: String, disableCompression: Bool? = nil) { - self.archivePath = archivePath - self.disableCompression = disableCompression - } -} - -public struct DBEncryptionConfig: Codable { - public init(currentKey: String, newKey: String) { - self.currentKey = currentKey - self.newKey = newKey - } - - public var currentKey: String - public var newKey: String -} - public enum ServerProtocol: String, Decodable { case smp case xftp } -public enum OperatorTag: String, Codable { - case simplex = "simplex" - case flux = "flux" -} - -public struct ServerOperatorInfo { - public var description: [String] - public var website: URL - public var selfhost: (text: String, link: URL)? = nil - public var logo: String - public var largeLogo: String - public var logoDarkMode: String - public var largeLogoDarkMode: String -} - -public let operatorsInfo: Dictionary = [ - .simplex: ServerOperatorInfo( - description: [ - "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or identity keys.", - "SimpleX Chat Ltd develops the communication software for SimpleX network." - ], - website: URL(string: "https://simplex.chat")!, - logo: "decentralized", - largeLogo: "logo", - logoDarkMode: "decentralized-light", - largeLogoDarkMode: "logo-light" - ), - .flux: ServerOperatorInfo( - description: [ - "Flux is the largest decentralized cloud, based on a global network of user-operated nodes.", - "Flux offers a powerful, scalable, and affordable cutting edge technology platform for all.", - "Flux operates servers in SimpleX network to improve its privacy and decentralization." - ], - website: URL(string: "https://runonflux.com")!, - selfhost: (text: "Self-host SimpleX servers on Flux", link: URL(string: "https://home.runonflux.io/apps/marketplace?q=simplex")!), - logo: "flux_logo_symbol", - largeLogo: "flux_logo", - logoDarkMode: "flux_logo_symbol", - largeLogoDarkMode: "flux_logo-light" - ), -] - -public struct UsageConditions: Decodable { - public var conditionsId: Int64 - public var conditionsCommit: String - public var notifiedAt: Date? - public var createdAt: Date - - public static var sampleData = UsageConditions( - conditionsId: 1, - conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c", - notifiedAt: nil, - createdAt: Date.now - ) -} - -public enum UsageConditionsAction: Decodable { - case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool) - case accepted(operators: [ServerOperator]) - - public var showNotice: Bool { - switch self { - case let .review(_, _, showNotice): showNotice - case .accepted: false - } - } -} - -public struct ServerOperatorConditions: Decodable { - public var serverOperators: [ServerOperator] - public var currentConditions: UsageConditions - public var conditionsAction: UsageConditionsAction? - - public static var empty = ServerOperatorConditions( - serverOperators: [], - currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now), - conditionsAction: nil - ) -} - -public enum ConditionsAcceptance: Equatable, Codable, Hashable { - case accepted(acceptedAt: Date?, autoAccepted: Bool) - // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. - // No deadline indicates it's required to accept conditions for the operator to start using it. - case required(deadline: Date?) - - public var conditionsAccepted: Bool { - switch self { - case .accepted: true - case .required: false - } - } - - public var usageAllowed: Bool { - switch self { - case .accepted: true - case let .required(deadline): deadline != nil - } - } -} - -public struct ServerOperator: Identifiable, Equatable, Codable { - public var operatorId: Int64 - public var operatorTag: OperatorTag? - public var tradeName: String - public var legalName: String? - public var serverDomains: [String] - public var conditionsAcceptance: ConditionsAcceptance - public var enabled: Bool - public var smpRoles: ServerRoles - public var xftpRoles: ServerRoles - - public var id: Int64 { operatorId } - - public static func == (l: ServerOperator, r: ServerOperator) -> Bool { - l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName && - l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled && - l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles - } - - public var legalName_: String { - legalName ?? tradeName - } - - public var info: ServerOperatorInfo { - return if let operatorTag = operatorTag { - operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo - } else { - ServerOperator.dummyOperatorInfo - } - } - - public static let dummyOperatorInfo = ServerOperatorInfo( - description: ["Default"], - website: URL(string: "https://simplex.chat")!, - logo: "decentralized", - largeLogo: "logo", - logoDarkMode: "decentralized-light", - largeLogoDarkMode: "logo-light" - ) - - public func logo(_ colorScheme: ColorScheme) -> String { - colorScheme == .light ? info.logo : info.logoDarkMode - } - - public func largeLogo(_ colorScheme: ColorScheme) -> String { - colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode - } - - public static var sampleData1 = ServerOperator( - operatorId: 1, - operatorTag: .simplex, - tradeName: "SimpleX Chat", - legalName: "SimpleX Chat Ltd", - serverDomains: ["simplex.im"], - conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), - enabled: true, - smpRoles: ServerRoles(storage: true, proxy: true), - xftpRoles: ServerRoles(storage: true, proxy: true) - ) -} - -public struct ServerRoles: Equatable, Codable { - public var storage: Bool - public var proxy: Bool -} - -public struct UserOperatorServers: Identifiable, Equatable, Codable { - public var `operator`: ServerOperator? - public var smpServers: [UserServer] - public var xftpServers: [UserServer] - - public var id: String { - if let op = self.operator { - "\(op.operatorId)" - } else { - "nil operator" - } - } - - public var operator_: ServerOperator { - get { - self.operator ?? ServerOperator( - operatorId: 0, - operatorTag: nil, - tradeName: "", - legalName: "", - serverDomains: [], - conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), - enabled: false, - smpRoles: ServerRoles(storage: true, proxy: true), - xftpRoles: ServerRoles(storage: true, proxy: true) - ) - } - set { `operator` = newValue } - } - - public static var sampleData1 = UserOperatorServers( - operator: ServerOperator.sampleData1, - smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] - ) - - public static var sampleDataNilOperator = UserOperatorServers( - operator: nil, - smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] - ) -} - -public enum UserServersError: Decodable { - case noServers(protocol: ServerProtocol, user: UserRef?) - case storageMissing(protocol: ServerProtocol, user: UserRef?) - case proxyMissing(protocol: ServerProtocol, user: UserRef?) - case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) - - public var globalError: String? { - switch self { - case let .noServers(`protocol`, _): - switch `protocol` { - case .smp: return globalSMPError - case .xftp: return globalXFTPError - } - case let .storageMissing(`protocol`, _): - switch `protocol` { - case .smp: return globalSMPError - case .xftp: return globalXFTPError - } - case let .proxyMissing(`protocol`, _): - switch `protocol` { - case .smp: return globalSMPError - case .xftp: return globalXFTPError - } - default: return nil - } - } - - public var globalSMPError: String? { - switch self { - case let .noServers(.smp, user): - let text = NSLocalizedString("No message servers.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .storageMissing(.smp, user): - let text = NSLocalizedString("No servers to receive messages.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .proxyMissing(.smp, user): - let text = NSLocalizedString("No servers for private message routing.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - default: - return nil - } - } - - public var globalXFTPError: String? { - switch self { - case let .noServers(.xftp, user): - let text = NSLocalizedString("No media & file servers.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .storageMissing(.xftp, user): - let text = NSLocalizedString("No servers to send files.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .proxyMissing(.xftp, user): - let text = NSLocalizedString("No servers to receive files.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - default: - return nil - } - } - - private func userStr(_ user: UserRef) -> String { - String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName) - } -} - -public struct UserServer: Identifiable, Equatable, Codable, Hashable { - public var serverId: Int64? - public var server: String - public var preset: Bool - public var tested: Bool? - public var enabled: Bool - public var deleted: Bool - var createdAt = Date() - - public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) { - self.serverId = serverId - self.server = server - self.preset = preset - self.tested = tested - self.enabled = enabled - self.deleted = deleted - } - - public static func == (l: UserServer, r: UserServer) -> Bool { - l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested && - l.enabled == r.enabled && l.deleted == r.deleted - } - - public var id: String { "\(server) \(createdAt)" } - - public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false) - - public var isEmpty: Bool { - server.trimmingCharacters(in: .whitespaces) == "" - } - - public struct SampleData { - public var preset: UserServer - public var custom: UserServer - public var untested: UserServer - public var xftpPreset: UserServer - } - - public static var sampleData = SampleData( - preset: UserServer( - serverId: 1, - server: "smp://abcd@smp8.simplex.im", - preset: true, - tested: true, - enabled: true, - deleted: false - ), - custom: UserServer( - serverId: 2, - server: "smp://abcd@smp9.simplex.im", - preset: false, - tested: false, - enabled: false, - deleted: false - ), - untested: UserServer( - serverId: 3, - server: "smp://abcd@smp10.simplex.im", - preset: false, - tested: nil, - enabled: true, - deleted: false - ), - xftpPreset: UserServer( - serverId: 4, - server: "xftp://abcd@xftp8.simplex.im", - preset: true, - tested: true, - enabled: true, - deleted: false - ) - ) - - enum CodingKeys: CodingKey { - case serverId - case server - case preset - case tested - case enabled - case deleted - } -} - -public enum ProtocolTestStep: String, Decodable, Equatable { - case connect - case disconnect - case createQueue - case secureQueue - case deleteQueue - case createFile - case uploadFile - case downloadFile - case compareFile - case deleteFile - - var text: String { - switch self { - case .connect: return NSLocalizedString("Connect", comment: "server test step") - case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step") - case .createQueue: return NSLocalizedString("Create queue", comment: "server test step") - case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step") - case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step") - case .createFile: return NSLocalizedString("Create file", comment: "server test step") - case .uploadFile: return NSLocalizedString("Upload file", comment: "server test step") - case .downloadFile: return NSLocalizedString("Download file", comment: "server test step") - case .compareFile: return NSLocalizedString("Compare file", comment: "server test step") - case .deleteFile: return NSLocalizedString("Delete file", comment: "server test step") - } - } -} - -public struct ProtocolTestFailure: Decodable, Error, Equatable { - public var testStep: ProtocolTestStep - public var testError: AgentErrorType - - public static func == (l: ProtocolTestFailure, r: ProtocolTestFailure) -> Bool { - l.testStep == r.testStep - } - - public var localizedDescription: String { - let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "server test failure"), testStep.text) - switch testError { - case .SMP(_, .AUTH): - return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error") - case .XFTP(.AUTH): - return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error") - case .BROKER(_, .NETWORK): - return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error") - default: - return err - } - } -} - public struct ServerAddress: Decodable { public var serverProtocol: ServerProtocol public var hostnames: [String] @@ -1744,7 +240,7 @@ public struct NetCfg: Codable, Equatable { public var sessionMode = TransportSessionMode.user public var smpProxyMode: SMPProxyMode = .always public var smpProxyFallback: SMPProxyFallback = .allowProtected - public var smpWebPort = false + public var smpWebPortServers: SMPWebPortServers = .preset public var tcpConnectTimeout: Int // microseconds public var tcpTimeout: Int // microseconds public var tcpTimeoutPerKb: Int // microseconds @@ -1769,7 +265,7 @@ public struct NetCfg: Codable, Equatable { rcvConcurrency: 8, smpPingInterval: 1200_000_000 ) - + public var withProxyTimeouts: NetCfg { var cfg = self cfg.tcpConnectTimeout = NetCfg.proxyDefaults.tcpConnectTimeout @@ -1779,7 +275,7 @@ public struct NetCfg: Codable, Equatable { cfg.smpPingInterval = NetCfg.proxyDefaults.smpPingInterval return cfg } - + public var hasProxyTimeouts: Bool { tcpConnectTimeout == NetCfg.proxyDefaults.tcpConnectTimeout && tcpTimeout == NetCfg.proxyDefaults.tcpTimeout && @@ -1840,6 +336,20 @@ public enum SMPProxyFallback: String, Codable, SelectableItem { public static let values: [SMPProxyFallback] = [.allow, .allowProtected, .prohibit] } +public enum SMPWebPortServers: String, Codable, CaseIterable { + case all = "all" + case preset = "preset" + case off = "off" + + public var text: LocalizedStringKey { + switch self { + case .all: "All servers" + case .preset: "Preset servers" + case .off: "Off" + } + } +} + public enum OnionHosts: String, Identifiable { case no case prefer @@ -1912,7 +422,7 @@ public struct NetworkProxy: Equatable, Codable { public static var def: NetworkProxy { NetworkProxy() } - + public var valid: Bool { let hostOk = switch NWEndpoint.Host(host) { case .ipv4: true @@ -1923,7 +433,7 @@ public struct NetworkProxy: Equatable, Codable { port > 0 && port <= 65535 && NetworkProxy.validCredential(username) && NetworkProxy.validCredential(password) } - + public static func validCredential(_ s: String) -> Bool { !s.contains(":") && !s.contains("@") } @@ -1959,56 +469,6 @@ public enum NetworkProxyAuth: String, Codable { case isolate } -public enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(connectionError: String) - - public var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } - } - - public var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - public var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } -} - -public enum ForwardConfirmation: Decodable, Hashable { - case filesNotAccepted(fileIds: [Int64]) - case filesInProgress(filesCount: Int) - case filesMissing(filesCount: Int) - case filesFailed(filesCount: Int) -} - -public struct ConnNetworkStatus: Decodable { - public var agentConnId: String - public var networkStatus: NetworkStatus -} - public struct ChatSettings: Codable, Hashable { public var enableNtfs: MsgFilter public var sendRcpts: Bool? @@ -2023,19 +483,54 @@ public struct ChatSettings: Codable, Hashable { public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false) } +public struct NavigationInfo: Decodable { + public var afterUnread: Int = 0 + public var afterTotal: Int = 0 + + public init(afterUnread: Int = 0, afterTotal: Int = 0) { + self.afterUnread = afterUnread + self.afterTotal = afterTotal + } +} + public enum MsgFilter: String, Codable, Hashable { case none case all case mentions -} - -public struct UserMsgReceiptSettings: Codable { - public var enable: Bool - public var clearOverrides: Bool - - public init(enable: Bool, clearOverrides: Bool) { - self.enable = enable - self.clearOverrides = clearOverrides + + public func nextMode(mentions: Bool) -> MsgFilter { + switch self { + case .all: mentions ? .mentions : .none + case .mentions: .none + case .none: .all + } + } + + public func text(mentions: Bool) -> String { + switch self { + case .all: NSLocalizedString("Unmute", comment: "notification label action") + case .mentions: NSLocalizedString("Mute", comment: "notification label action") + case .none: + mentions + ? NSLocalizedString("Mute all", comment: "notification label action") + : NSLocalizedString("Mute", comment: "notification label action") + } + } + + public var icon: String { + return switch self { + case .all: "speaker.wave.2" + case .mentions: "speaker.badge.exclamationmark" + case .none: "speaker.slash" + } + } + + public var iconFilled: String { + return switch self { + case .all: "speaker.wave.2.fill" + case .mentions: "speaker.badge.exclamationmark.fill" + case .none: "speaker.slash.fill" + } } } @@ -2106,108 +601,11 @@ public enum RatchetSyncState: String, Decodable { case agreed } -public struct UserContactLink: Decodable, Hashable { - public var connReqContact: String - public var autoAccept: AutoAccept? - - public init(connReqContact: String, autoAccept: AutoAccept? = nil) { - self.connReqContact = connReqContact - self.autoAccept = autoAccept - } - - var responseDetails: String { - "connReqContact: \(connReqContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" - } -} - -public struct AutoAccept: Codable, Hashable { - public var businessAddress: Bool - public var acceptIncognito: Bool - public var autoReply: MsgContent? - - public init(businessAddress: Bool, acceptIncognito: Bool, autoReply: MsgContent? = nil) { - self.businessAddress = businessAddress - self.acceptIncognito = acceptIncognito - self.autoReply = autoReply - } - - static func cmdString(_ autoAccept: AutoAccept?) -> String { - guard let autoAccept = autoAccept else { return "off" } - var s = "on" - if autoAccept.acceptIncognito { - s += " incognito=on" - } else if autoAccept.businessAddress { - s += " business" - } - guard let msg = autoAccept.autoReply else { return s } - return s + " " + msg.cmdString - } -} - public protocol SelectableItem: Identifiable, Equatable { var label: LocalizedStringKey { get } static var values: [Self] { get } } -public struct DeviceToken: Decodable { - var pushProvider: PushProvider - var token: String - - public init(pushProvider: PushProvider, token: String) { - self.pushProvider = pushProvider - self.token = token - } - - public var cmdString: String { - "\(pushProvider) \(token)" - } -} - -public enum PushEnvironment: String { - case development - case production -} - -public enum PushProvider: String, Decodable { - case apns_dev - case apns_prod - - public init(env: PushEnvironment) { - switch env { - case .development: self = .apns_dev - case .production: self = .apns_prod - } - } -} - -// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable, -// and .local for periodic background checks -public enum NotificationsMode: String, Decodable, SelectableItem { - case off = "OFF" - case periodic = "PERIODIC" - case instant = "INSTANT" - - public var label: LocalizedStringKey { - switch self { - case .off: "No push server" - case .periodic: "Periodic" - case .instant: "Instant" - } - } - - public var icon: String { - switch self { - case .off: return "arrow.clockwise" - case .periodic: return "timer" - case .instant: return "bolt" - } - } - - public var id: String { self.rawValue } - - public static var values: [NotificationsMode] = [.instant, .periodic, .off] -} - public enum NotificationPreviewMode: String, SelectableItem, Codable { case hidden case contact @@ -2226,63 +624,6 @@ public enum NotificationPreviewMode: String, SelectableItem, Codable { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } -public enum PrivacyChatListOpenLinksMode: String, CaseIterable, Codable, RawRepresentable, Identifiable { - case yes - case no - case ask - - public var id: Self { self } - - public var text: LocalizedStringKey { - switch self { - case .yes: return "Yes" - case .no: return "No" - case .ask: return "Ask" - } - } -} - -public struct RemoteCtrlInfo: Decodable { - public var remoteCtrlId: Int64 - public var ctrlDeviceName: String - public var sessionState: RemoteCtrlSessionState? - - public var deviceViewName: String { - ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName - } -} - -public enum RemoteCtrlSessionState: Decodable { - case starting - case searching - case connecting - case pendingConfirmation(sessionCode: String) - case connected(sessionCode: String) -} - -public enum RemoteCtrlStopReason: Decodable { - case discoveryFailed(chatError: ChatError) - case connectionFailed(chatError: ChatError) - case setupFailed(chatError: ChatError) - case disconnected -} - -public struct CtrlAppInfo: Decodable { - public var appVersionRange: AppVersionRange - public var deviceName: String -} - -public struct AppVersionRange: Decodable { - public var minVersion: String - public var maxVersion: String -} - -public struct CoreVersionInfo: Decodable { - public var version: String - public var simplexmqVersion: String - public var simplexmqCommit: String -} - public func decodeJSON(_ json: String) -> T? { if let data = json.data(using: .utf8) { return try? jsonDecoder.decode(T.self, from: data) @@ -2299,13 +640,26 @@ private func encodeCJSON(_ value: T) -> [CChar] { encodeJSON(value).cString(using: .utf8)! } -public enum ChatError: Decodable, Hashable { +public enum ChatError: Decodable, Hashable, Error { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) case errorDatabase(databaseError: DatabaseError) case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) - case invalidJSON(json: String) + case invalidJSON(json: Data?) // additional case used to pass errors that failed to parse + case unexpectedResult(type: String) // additional case used to pass unexpected responses + + public var errorType: String { + switch self { + case .error: "chat" + case .errorAgent: "agent" + case .errorStore: "store" + case .errorDatabase: "database" + case .errorRemoteCtrl: "remoteCtrl" + case .invalidJSON: "invalid" + case let .unexpectedResult(type): "! \(type)" + } + } } public enum ChatErrorType: Decodable, Hashable { @@ -2328,8 +682,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) @@ -2445,6 +799,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 { @@ -2634,425 +989,14 @@ public enum RemoteCtrlError: Decodable, Hashable { case protocolError } -public struct MigrationFileLinkData: Codable { - let networkConfig: NetworkConfig? - - public init(networkConfig: NetworkConfig) { - self.networkConfig = networkConfig - } - - public struct NetworkConfig: Codable { - let socksProxy: String? - let networkProxy: NetworkProxy? - let hostMode: HostMode? - let requiredHostMode: Bool? - - public init(socksProxy: String?, networkProxy: NetworkProxy?, hostMode: HostMode?, requiredHostMode: Bool?) { - self.socksProxy = socksProxy - self.networkProxy = networkProxy - self.hostMode = hostMode - self.requiredHostMode = requiredHostMode - } - - public func transformToPlatformSupported() -> NetworkConfig { - return if let hostMode, let requiredHostMode { - NetworkConfig( - socksProxy: nil, - networkProxy: nil, - hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode, - requiredHostMode: requiredHostMode - ) - } else { self } - } - } - - public func addToLink(link: String) -> String { - "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)" - } - - public static func readFromLink(link: String) -> MigrationFileLinkData? { -// standaloneFileInfo(link) - nil - } -} - -public struct AppSettings: Codable, Equatable { - public var networkConfig: NetCfg? = nil - public var networkProxy: NetworkProxy? = nil - public var privacyEncryptLocalFiles: Bool? = nil - public var privacyAskToApproveRelays: Bool? = nil - public var privacyAcceptImages: Bool? = nil - public var privacyLinkPreviews: Bool? = nil - public var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = nil - public var privacyShowChatPreviews: Bool? = nil - public var privacySaveLastDraft: Bool? = nil - public var privacyProtectScreen: Bool? = nil - public var privacyMediaBlurRadius: Int? = nil - public var notificationMode: AppSettingsNotificationMode? = nil - public var notificationPreviewMode: NotificationPreviewMode? = nil - public var webrtcPolicyRelay: Bool? = nil - public var webrtcICEServers: [String]? = nil - public var confirmRemoteSessions: Bool? = nil - public var connectRemoteViaMulticast: Bool? = nil - public var connectRemoteViaMulticastAuto: Bool? = nil - public var developerTools: Bool? = nil - public var confirmDBUpgrades: Bool? = nil - public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil - public var iosCallKitEnabled: Bool? = nil - public var iosCallKitCallsInRecents: Bool? = nil - public var uiProfileImageCornerRadius: Double? = nil - public var uiChatItemRoundness: Double? = nil - public var uiChatItemTail: Bool? = nil - public var uiColorScheme: String? = nil - public var uiDarkColorScheme: String? = nil - public var uiCurrentThemeIds: [String: String]? = nil - public var uiThemes: [ThemeOverrides]? = nil - public var oneHandUI: Bool? = nil - public var chatBottomBar: Bool? = nil - - public func prepareForExport() -> AppSettings { - var empty = AppSettings() - let def = AppSettings.defaults - if networkConfig != def.networkConfig { empty.networkConfig = networkConfig } - if networkProxy != def.networkProxy { empty.networkProxy = networkProxy } - if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } - if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } - if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } - if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } - if privacyChatListOpenLinks != def.privacyChatListOpenLinks { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } - if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } - if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } - if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } - if privacyMediaBlurRadius != def.privacyMediaBlurRadius { empty.privacyMediaBlurRadius = privacyMediaBlurRadius } - if notificationMode != def.notificationMode { empty.notificationMode = notificationMode } - if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode } - if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay } - if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers } - if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions } - if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast } - if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto } - if developerTools != def.developerTools { empty.developerTools = developerTools } - if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades } - if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen } - if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled } - if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } - if uiProfileImageCornerRadius != def.uiProfileImageCornerRadius { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius } - if uiChatItemRoundness != def.uiChatItemRoundness { empty.uiChatItemRoundness = uiChatItemRoundness } - if uiChatItemTail != def.uiChatItemTail { empty.uiChatItemTail = uiChatItemTail } - if uiColorScheme != def.uiColorScheme { empty.uiColorScheme = uiColorScheme } - if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme } - if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds } - if uiThemes != def.uiThemes { empty.uiThemes = uiThemes } - if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } - if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar } - return empty - } - - public static var defaults: AppSettings { - AppSettings ( - networkConfig: NetCfg.defaults, - networkProxy: NetworkProxy.def, - privacyEncryptLocalFiles: true, - privacyAskToApproveRelays: true, - privacyAcceptImages: true, - privacyLinkPreviews: true, - privacyChatListOpenLinks: .ask, - privacyShowChatPreviews: true, - privacySaveLastDraft: true, - privacyProtectScreen: false, - privacyMediaBlurRadius: 0, - notificationMode: AppSettingsNotificationMode.instant, - notificationPreviewMode: NotificationPreviewMode.message, - webrtcPolicyRelay: true, - webrtcICEServers: [], - confirmRemoteSessions: false, - connectRemoteViaMulticast: true, - connectRemoteViaMulticastAuto: true, - developerTools: false, - confirmDBUpgrades: false, - androidCallOnLockScreen: AppSettingsLockScreenCalls.show, - iosCallKitEnabled: true, - iosCallKitCallsInRecents: false, - uiProfileImageCornerRadius: 22.5, - uiChatItemRoundness: 0.75, - uiChatItemTail: true, - uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME, - uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName, - uiCurrentThemeIds: nil as [String: String]?, - uiThemes: nil as [ThemeOverrides]?, - oneHandUI: true, - chatBottomBar: true - ) - } -} - -public enum AppSettingsNotificationMode: String, Codable { - case off - case periodic - case instant - - public func toNotificationsMode() -> NotificationsMode { - switch self { - case .instant: .instant - case .periodic: .periodic - case .off: .off - } - } - - public static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode { - switch mode { - case .instant: .instant - case .periodic: .periodic - case .off: .off - } - } -} - -//public enum NotificationPreviewMode: Codable { -// case hidden -// case contact -// case message -//} - -public enum AppSettingsLockScreenCalls: String, Codable { - case disable - case show - case accept -} - -public struct UserNetworkInfo: Codable, Equatable { - public let networkType: UserNetworkType - public let online: Bool - - public init(networkType: UserNetworkType, online: Bool) { - self.networkType = networkType - self.online = online - } -} - -public enum UserNetworkType: String, Codable { - case none - case cellular - case wifi - case ethernet - case other - - public var text: LocalizedStringKey { - switch self { - case .none: "No network connection" - case .cellular: "Cellular" - case .wifi: "WiFi" - case .ethernet: "Wired ethernet" - case .other: "Other" - } - } -} - -public struct RcvMsgInfo: Codable { - var msgId: Int64 - var msgDeliveryId: Int64 - var msgDeliveryStatus: String - var agentMsgId: Int64 - var agentMsgMeta: String -} - -public struct ServerQueueInfo: Codable { - var server: String - var rcvId: String - var sndId: String - var ntfId: String? - var status: String - var info: QueueInfo -} - -public struct QueueInfo: Codable { - var qiSnd: Bool - var qiNtf: Bool - var qiSub: QSub? - var qiSize: Int - var qiMsg: MsgInfo? -} - -public struct QSub: Codable { - var qSubThread: QSubThread - var qDelivered: String? -} - -public enum QSubThread: String, Codable { - case noSub - case subPending - case subThread - case prohibitSub -} - -public struct MsgInfo: Codable { - var msgId: String - var msgTs: Date - var msgType: MsgType -} - -public enum MsgType: String, Codable { - case message - case quota -} - public struct AppFilePaths: Encodable { public let appFilesFolder: String public let appTempFolder: String public let appAssetsFolder: String -} - -public struct PresentedServersSummary: Codable { - public var statsStartedAt: Date - public var allUsersSMP: SMPServersSummary - public var allUsersXFTP: XFTPServersSummary - public var currentUserSMP: SMPServersSummary - public var currentUserXFTP: XFTPServersSummary -} - -public struct SMPServersSummary: Codable { - public var smpTotals: SMPTotals - public var currentlyUsedSMPServers: [SMPServerSummary] - public var previouslyUsedSMPServers: [SMPServerSummary] - public var onlyProxiedSMPServers: [SMPServerSummary] -} - -public struct SMPTotals: Codable { - public var sessions: ServerSessions - public var subs: SMPServerSubs - public var stats: AgentSMPServerStatsData -} - -public struct SMPServerSummary: Codable, Identifiable { - public var smpServer: String - public var known: Bool? - public var sessions: ServerSessions? - public var subs: SMPServerSubs? - public var stats: AgentSMPServerStatsData? - - public var id: String { smpServer } - - public var hasSubs: Bool { subs != nil } - - public var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions } - - public var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs } -} - -public struct ServerSessions: Codable { - public var ssConnected: Int - public var ssErrors: Int - public var ssConnecting: Int - - static public var newServerSessions = ServerSessions( - ssConnected: 0, - ssErrors: 0, - ssConnecting: 0 - ) - - public var hasSess: Bool { ssConnected > 0 } -} - -public struct SMPServerSubs: Codable { - public var ssActive: Int - public var ssPending: Int - - public init(ssActive: Int, ssPending: Int) { - self.ssActive = ssActive - self.ssPending = ssPending - } - - static public var newSMPServerSubs = SMPServerSubs( - ssActive: 0, - ssPending: 0 - ) - - public var total: Int { ssActive + ssPending } - - public var shareOfActive: Double { - guard total != 0 else { return 0.0 } - return Double(ssActive) / Double(total) + + public init(appFilesFolder: String, appTempFolder: String, appAssetsFolder: String) { + self.appFilesFolder = appFilesFolder + self.appTempFolder = appTempFolder + self.appAssetsFolder = appAssetsFolder } } - -public struct AgentSMPServerStatsData: Codable { - public var _sentDirect: Int - public var _sentViaProxy: Int - public var _sentProxied: Int - public var _sentDirectAttempts: Int - public var _sentViaProxyAttempts: Int - public var _sentProxiedAttempts: Int - public var _sentAuthErrs: Int - public var _sentQuotaErrs: Int - public var _sentExpiredErrs: Int - public var _sentOtherErrs: Int - public var _recvMsgs: Int - public var _recvDuplicates: Int - public var _recvCryptoErrs: Int - public var _recvErrs: Int - public var _ackMsgs: Int - public var _ackAttempts: Int - public var _ackNoMsgErrs: Int - public var _ackOtherErrs: Int - public var _connCreated: Int - public var _connSecured: Int - public var _connCompleted: Int - public var _connDeleted: Int - public var _connDelAttempts: Int - public var _connDelErrs: Int - public var _connSubscribed: Int - public var _connSubAttempts: Int - public var _connSubIgnored: Int - public var _connSubErrs: Int - public var _ntfKey: Int - public var _ntfKeyAttempts: Int - public var _ntfKeyDeleted: Int - public var _ntfKeyDeleteAttempts: Int -} - -public struct XFTPServersSummary: Codable { - public var xftpTotals: XFTPTotals - public var currentlyUsedXFTPServers: [XFTPServerSummary] - public var previouslyUsedXFTPServers: [XFTPServerSummary] -} - -public struct XFTPTotals: Codable { - public var sessions: ServerSessions - public var stats: AgentXFTPServerStatsData -} - -public struct XFTPServerSummary: Codable, Identifiable { - public var xftpServer: String - public var known: Bool? - public var sessions: ServerSessions? - public var stats: AgentXFTPServerStatsData? - public var rcvInProgress: Bool - public var sndInProgress: Bool - public var delInProgress: Bool - - public var id: String { xftpServer } -} - -public struct AgentXFTPServerStatsData: Codable { - public var _uploads: Int - public var _uploadsSize: Int64 - public var _uploadAttempts: Int - public var _uploadErrs: Int - public var _downloads: Int - public var _downloadsSize: Int64 - public var _downloadAttempts: Int - public var _downloadAuthErrs: Int - public var _downloadErrs: Int - public var _deletions: Int - public var _deleteAttempts: Int - public var _deleteErrs: Int -} - -public struct AgentNtfServerStatsData: Codable { - public var _ntfCreated: Int - public var _ntfCreateAttempts: Int - public var _ntfChecked: Int - public var _ntfCheckAttempts: Int - public var _ntfDeleted: Int - public var _ntfDelAttempts: Int -} diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index c754f0740d..29ccab7357 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -40,6 +40,7 @@ let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE = "networkSMPProxyMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK = "networkSMPProxyFallback" +let GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS = "networkSMPWebPortServers" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -71,6 +72,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue, + GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb, @@ -249,6 +251,12 @@ public let networkSMPProxyFallbackGroupDefault = EnumDefault( withDefault: .allowProtected ) +public let networkSMPWebPortServersDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS, + withDefault: .preset +) + public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE) public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE) @@ -303,12 +311,14 @@ public class EnumDefault where T.RawValue == String { } public class BoolDefault: Default { + @inline(__always) public func get() -> Bool { self.defaults.bool(forKey: self.key) } } public class IntDefault: Default { + @inline(__always) public func get() -> Int { self.defaults.integer(forKey: self.key) } @@ -318,11 +328,13 @@ public class Default { var defaults: UserDefaults var key: String + @inline(__always) public init(defaults: UserDefaults = UserDefaults.standard, forKey: String) { self.defaults = defaults self.key = forKey } + @inline(__always) public func set(_ value: T) { defaults.set(value, forKey: key) defaults.synchronize() @@ -336,6 +348,7 @@ public func getNetCfg() -> NetCfg { let sessionMode = networkSessionModeGroupDefault.get() let smpProxyMode = networkSMPProxyModeGroupDefault.get() let smpProxyFallback = networkSMPProxyFallbackGroupDefault.get() + let smpWebPortServers = networkSMPWebPortServersDefault.get() let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let tcpTimeoutPerKb = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) @@ -359,7 +372,7 @@ public func getNetCfg() -> NetCfg { sessionMode: sessionMode, smpProxyMode: smpProxyMode, smpProxyFallback: smpProxyFallback, - smpWebPort: false, + smpWebPortServers: smpWebPortServers, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpTimeoutPerKb: tcpTimeoutPerKb, @@ -378,6 +391,7 @@ public func setNetCfg(_ cfg: NetCfg, networkProxy: NetworkProxy?) { networkSMPProxyFallbackGroupDefault.set(cfg.smpProxyFallback) let socksProxy = networkProxy?.toProxyString() groupDefaults.set(socksProxy, forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) + networkSMPWebPortServersDefault.set(cfg.smpWebPortServers) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.tcpTimeoutPerKb, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ae49ee3f3f..9a82c912dd 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -15,6 +15,9 @@ public let CREATE_MEMBER_CONTACT_VERSION = 2 // version to receive reports (MCReport) public let REPORTS_VERSION = 12 +// support group knocking (MsgScope) +public let GROUP_KNOCKING_VERSION = 15 + public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")! public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { @@ -711,6 +714,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case voice case files case simplexLinks + case reports case history public var id: Self { self } @@ -731,6 +735,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: true case .files: true case .simplexLinks: true + case .reports: false case .history: false } } @@ -744,6 +749,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") case .files: return NSLocalizedString("Files and media", comment: "chat feature") case .simplexLinks: return NSLocalizedString("SimpleX links", comment: "chat feature") + case .reports: return NSLocalizedString("Member reports", comment: "chat feature") case .history: return NSLocalizedString("Visible history", comment: "chat feature") } } @@ -757,6 +763,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: return "mic" case .files: return "doc" case .simplexLinks: return "link.circle" + case .reports: return "flag" case .history: return "clock" } } @@ -770,6 +777,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: return "mic.fill" case .files: return "doc.fill" case .simplexLinks: return "link.circle.fill" + case .reports: return "flag.fill" case .history: return "clock.fill" } } @@ -819,6 +827,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .on: return "Allow to send SimpleX links." case .off: return "Prohibit sending SimpleX links." } + case .reports: + switch enabled { + case .on: return "Allow to report messsages to moderators." + case .off: return "Prohibit reporting messages to moderators." + } case .history: switch enabled { case .on: return "Send up to 100 last messages to new members." @@ -862,6 +875,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .on: return "Members can send SimpleX links." case .off: return "SimpleX links are prohibited." } + case .reports: + switch enabled { + case .on: return "Members can report messsages to moderators." + case .off: return "Reporting messages to moderators is prohibited." + } case .history: switch enabled { case .on: return "Up to 100 last messages are sent to new members." @@ -1007,6 +1025,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { public var voice: RoleGroupPreference public var files: RoleGroupPreference public var simplexLinks: RoleGroupPreference + public var reports: GroupPreference public var history: GroupPreference public init( @@ -1017,6 +1036,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { voice: RoleGroupPreference, files: RoleGroupPreference, simplexLinks: RoleGroupPreference, + reports: GroupPreference, history: GroupPreference ) { self.timedMessages = timedMessages @@ -1026,6 +1046,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { self.voice = voice self.files = files self.simplexLinks = simplexLinks + self.reports = reports self.history = history } @@ -1037,6 +1058,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { voice: RoleGroupPreference(enable: .on, role: nil), files: RoleGroupPreference(enable: .on, role: nil), simplexLinks: RoleGroupPreference(enable: .on, role: nil), + reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on) ) } @@ -1049,6 +1071,7 @@ public struct GroupPreferences: Codable, Hashable { public var voice: RoleGroupPreference? public var files: RoleGroupPreference? public var simplexLinks: RoleGroupPreference? + public var reports: GroupPreference? public var history: GroupPreference? public init( @@ -1059,6 +1082,7 @@ public struct GroupPreferences: Codable, Hashable { voice: RoleGroupPreference? = nil, files: RoleGroupPreference? = nil, simplexLinks: RoleGroupPreference? = nil, + reports: GroupPreference? = nil, history: GroupPreference? = nil ) { self.timedMessages = timedMessages @@ -1068,6 +1092,7 @@ public struct GroupPreferences: Codable, Hashable { self.voice = voice self.files = files self.simplexLinks = simplexLinks + self.reports = reports self.history = history } @@ -1079,6 +1104,7 @@ public struct GroupPreferences: Codable, Hashable { voice: RoleGroupPreference(enable: .on, role: nil), files: RoleGroupPreference(enable: .on, role: nil), simplexLinks: RoleGroupPreference(enable: .on, role: nil), + reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on) ) } @@ -1092,6 +1118,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group voice: fullPreferences.voice, files: fullPreferences.files, simplexLinks: fullPreferences.simplexLinks, + reports: fullPreferences.reports, history: fullPreferences.history ) } @@ -1173,11 +1200,11 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) - case group(groupInfo: GroupInfo) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) case local(noteFolder: NoteFolder) case contactRequest(contactRequest: UserContactRequest) case contactConnection(contactConnection: PendingContactConnection) - case invalidJSON(json: String) + case invalidJSON(json: Data?) private static let invalidChatName = NSLocalizedString("invalid chat", comment: "invalid chat data") @@ -1187,7 +1214,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.localDisplayName - case let .group(groupInfo): return groupInfo.localDisplayName + case let .group(groupInfo, _): return groupInfo.localDisplayName case .local: return "" case let .contactRequest(contactRequest): return contactRequest.localDisplayName case let .contactConnection(contactConnection): return contactConnection.localDisplayName @@ -1200,7 +1227,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.displayName - case let .group(groupInfo): return groupInfo.displayName + case let .group(groupInfo, _): return groupInfo.displayName case .local: return ChatInfo.privateNotesChatName case let .contactRequest(contactRequest): return contactRequest.displayName case let .contactConnection(contactConnection): return contactConnection.displayName @@ -1213,7 +1240,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.fullName - case let .group(groupInfo): return groupInfo.fullName + case let .group(groupInfo, _): return groupInfo.fullName case .local: return "" case let .contactRequest(contactRequest): return contactRequest.fullName case let .contactConnection(contactConnection): return contactConnection.fullName @@ -1226,7 +1253,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.image - case let .group(groupInfo): return groupInfo.image + case let .group(groupInfo, _): return groupInfo.image case .local: return nil case let .contactRequest(contactRequest): return contactRequest.image case let .contactConnection(contactConnection): return contactConnection.image @@ -1239,7 +1266,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.localAlias - case let .group(groupInfo): return groupInfo.localAlias + case let .group(groupInfo, _): return groupInfo.localAlias case .local: return "" case let .contactRequest(contactRequest): return contactRequest.localAlias case let .contactConnection(contactConnection): return contactConnection.localAlias @@ -1252,7 +1279,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.id - case let .group(groupInfo): return groupInfo.id + case let .group(groupInfo, _): return groupInfo.id case let .local(noteFolder): return noteFolder.id case let .contactRequest(contactRequest): return contactRequest.id case let .contactConnection(contactConnection): return contactConnection.id @@ -1278,7 +1305,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.apiId - case let .group(groupInfo): return groupInfo.apiId + case let .group(groupInfo, _): return groupInfo.apiId case let .local(noteFolder): return noteFolder.apiId case let .contactRequest(contactRequest): return contactRequest.apiId case let .contactConnection(contactConnection): return contactConnection.apiId @@ -1291,7 +1318,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.ready - case let .group(groupInfo): return groupInfo.ready + case let .group(groupInfo, _): return groupInfo.ready case let .local(noteFolder): return noteFolder.ready case let .contactRequest(contactRequest): return contactRequest.ready case let .contactConnection(contactConnection): return contactConnection.ready @@ -1309,24 +1336,60 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } - public var sendMsgEnabled: Bool { + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { get { switch self { - case let .direct(contact): return contact.sendMsgEnabled - case let .group(groupInfo): return groupInfo.sendMsgEnabled - case let .local(noteFolder): return noteFolder.sendMsgEnabled - case let .contactRequest(contactRequest): return contactRequest.sendMsgEnabled - case let .contactConnection(contactConnection): return contactConnection.sendMsgEnabled - case .invalidJSON: return false + case let .direct(contact): + // TODO [short links] this will have additional statuses for pending contact requests before they are accepted + if contact.nextSendGrpInv { return nil } + if !contact.active { return ("contact deleted", nil) } + if !contact.sndReady { return ("contact not ready", nil) } + if contact.activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false { return ("not synchronized", nil) } + if contact.activeConn?.connDisabled ?? true { return ("contact disabled", nil) } + return nil + case let .group(groupInfo, groupChatScope): + if groupInfo.membership.memberActive { + switch(groupChatScope) { + case .none: + if groupInfo.membership.memberPending { return ("reviewed by admins", "Please contact group admin.") } + if groupInfo.membership.memberRole == .observer { return ("you are observer", "Please contact group admin.") } + return nil + case let .some(.memberSupport(groupMember_: .some(supportMember))): + if supportMember.versionRange.maxVersion < GROUP_KNOCKING_VERSION && !supportMember.memberPending { + return ("member has old version", nil) + } + return nil + case .some(.memberSupport(groupMember_: .none)): + return nil + } + } else { + switch groupInfo.membership.memberStatus { + case .memRejected: return ("request to join rejected", nil) + case .memGroupDeleted: return ("group is deleted", nil) + case .memRemoved: return ("removed from group", nil) + case .memLeft: return ("you left", nil) + default: return ("can't send messages", nil) + } + } + case .local: + return nil + case .contactRequest: + return ("can't send messages", nil) + case .contactConnection: + return ("can't send messages", nil) + case .invalidJSON: + return ("can't send messages", nil) } } } + public var sendMsgEnabled: Bool { userCantSendReason == nil } + public var incognito: Bool { get { switch self { case let .direct(contact): return contact.contactConnIncognito - case let .group(groupInfo): return groupInfo.membership.memberIncognito + case let .group(groupInfo, _): return groupInfo.membership.memberIncognito case .local: return false case .contactRequest: return false case let .contactConnection(contactConnection): return contactConnection.incognito @@ -1351,7 +1414,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupInfo: GroupInfo? { switch self { - case let .group(groupInfo): return groupInfo + case let .group(groupInfo, _): return groupInfo default: return nil } } @@ -1368,7 +1431,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .voice: return cups.voice.enabled.forUser case .calls: return cups.calls.enabled.forUser } - case let .group(groupInfo): + case let .group(groupInfo, _): let prefs = groupInfo.fullGroupPreferences switch feature { case .timedMessages: return prefs.timedMessages.on @@ -1391,7 +1454,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case let .direct(contact): let pref = contact.mergedPreferences.timedMessages return pref.enabled.forUser ? pref.userPreference.preference.ttl : nil - case let .group(groupInfo): + case let .group(groupInfo, _): let pref = groupInfo.fullGroupPreferences.timedMessages return pref.on ? pref.ttl : nil default: @@ -1416,7 +1479,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } else { return .other } - case let .group(groupInfo): + case let .group(groupInfo, _): if !groupInfo.fullGroupPreferences.voice.on(for: groupInfo.membership) { return .groupOwnerCan } else { @@ -1448,22 +1511,45 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } - public var ntfsEnabled: Bool { - self.chatSettings?.enableNtfs == .all + public func groupChatScope() -> GroupChatScope? { + switch self { + case let .group(_, groupChatScope): groupChatScope?.toChatScope() + default: nil + } + } + + public func ntfsEnabled(chatItem: ChatItem) -> Bool { + ntfsEnabled(chatItem.meta.userMention) + } + + public func ntfsEnabled(_ userMention: Bool) -> Bool { + switch self.chatSettings?.enableNtfs { + case .all: true + case .mentions: userMention + default: false + } } public var chatSettings: ChatSettings? { switch self { case let .direct(contact): return contact.chatSettings - case let .group(groupInfo): return groupInfo.chatSettings + case let .group(groupInfo, _): return groupInfo.chatSettings default: return nil } } + public var nextNtfMode: MsgFilter? { + self.chatSettings?.enableNtfs.nextMode(mentions: hasMentions) + } + + public var hasMentions: Bool { + if case .group = self { true } else { false } + } + public var chatTags: [Int64]? { switch self { case let .direct(contact): return contact.chatTags - case let .group(groupInfo): return groupInfo.chatTags + case let .group(groupInfo, _): return groupInfo.chatTags default: return nil } } @@ -1471,7 +1557,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { var createdAt: Date { switch self { case let .direct(contact): return contact.createdAt - case let .group(groupInfo): return groupInfo.createdAt + case let .group(groupInfo, _): return groupInfo.createdAt case let .local(noteFolder): return noteFolder.createdAt case let .contactRequest(contactRequest): return contactRequest.createdAt case let .contactConnection(contactConnection): return contactConnection.createdAt @@ -1482,7 +1568,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var updatedAt: Date { switch self { case let .direct(contact): return contact.updatedAt - case let .group(groupInfo): return groupInfo.updatedAt + case let .group(groupInfo, _): return groupInfo.updatedAt case let .local(noteFolder): return noteFolder.updatedAt case let .contactRequest(contactRequest): return contactRequest.updatedAt case let .contactConnection(contactConnection): return contactConnection.updatedAt @@ -1493,7 +1579,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatTs: Date { switch self { case let .direct(contact): return contact.chatTs ?? contact.updatedAt - case let .group(groupInfo): return groupInfo.chatTs ?? groupInfo.updatedAt + case let .group(groupInfo, _): return groupInfo.chatTs ?? groupInfo.updatedAt case let .local(noteFolder): return noteFolder.chatTs case let .contactRequest(contactRequest): return contactRequest.updatedAt case let .contactConnection(contactConnection): return contactConnection.updatedAt @@ -1509,7 +1595,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } else { ChatTTL.userDefault(globalTTL) } - case let .group(groupInfo): + case let .group(groupInfo, _): return if let ciTTL = groupInfo.chatItemTTL { ChatTTL.chat(ChatItemTTL(ciTTL)) } else { @@ -1529,7 +1615,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public static var sampleData: ChatInfo.SampleData = SampleData( direct: ChatInfo.direct(contact: Contact.sampleData), - group: ChatInfo.group(groupInfo: GroupInfo.sampleData), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData, groupChatScope: nil), local: ChatInfo.local(noteFolder: NoteFolder.sampleData), contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData), contactConnection: ChatInfo.contactConnection(contactConnection: PendingContactConnection.getSampleData()) @@ -1549,7 +1635,7 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { self.chatStats = chatStats } - public static func invalidJSON(_ json: String) -> ChatData { + public static func invalidJSON(_ json: Data?) -> ChatData { ChatData( chatInfo: .invalidJSON(json: json), chatItems: [], @@ -1559,20 +1645,55 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } public struct ChatStats: Decodable, Hashable { - public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { + public init( + unreadCount: Int = 0, + unreadMentions: Int = 0, + reportsCount: Int = 0, + minUnreadItemId: Int64 = 0, + unreadChat: Bool = false + ) { self.unreadCount = unreadCount + self.unreadMentions = unreadMentions self.reportsCount = reportsCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } public var unreadCount: Int = 0 + public var unreadMentions: Int = 0 // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 public var minUnreadItemId: Int64 = 0 + // actual only via getChats(), otherwise, false public var unreadChat: Bool = false } +public enum GroupChatScope: Decodable { + case memberSupport(groupMemberId_: Int64?) +} + +public func sameChatScope(_ scope1: GroupChatScope, _ scope2: GroupChatScope) -> Bool { + switch (scope1, scope2) { + case let (.memberSupport(groupMemberId1_), .memberSupport(groupMemberId2_)): + return groupMemberId1_ == groupMemberId2_ + } +} + +public enum GroupChatScopeInfo: Decodable, Hashable { + case memberSupport(groupMember_: GroupMember?) + + public func toChatScope() -> GroupChatScope { + switch self { + case let .memberSupport(groupMember_): + if let groupMember = groupMember_ { + return .memberSupport(groupMemberId_: groupMember.groupMemberId) + } else { + return .memberSupport(groupMemberId_: nil) + } + } + } +} + public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var contactId: Int64 var localDisplayName: ContactName @@ -1599,15 +1720,6 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var ready: Bool { get { activeConn?.connStatus == .ready } } public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var active: Bool { get { contactStatus == .active } } - public var sendMsgEnabled: Bool { get { - ( - sndReady - && active - && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false) - && !(activeConn?.connDisabled ?? true) - ) - || nextSendGrpInv - } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } @@ -1786,7 +1898,6 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public var id: ChatId { get { "<@\(contactRequestId)" } } public var apiId: Int64 { get { contactRequestId } } var ready: Bool { get { true } } - public var sendMsgEnabled: Bool { get { false } } public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } @@ -1810,7 +1921,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 @@ -1818,7 +1929,6 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable { public var id: ChatId { get { ":\(pccConnId)" } } public var apiId: Int64 { get { pccConnId } } var ready: Bool { get { false } } - public var sendMsgEnabled: Bool { get { false } } var localDisplayName: String { get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } } @@ -1938,17 +2048,16 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var businessChat: BusinessChatInfo? public var fullGroupPreferences: FullGroupPreferences public var membership: GroupMember - public var hostConnCustomUserProfileId: Int64? public var chatSettings: ChatSettings var createdAt: Date var updatedAt: Date var chatTs: Date? public var uiThemes: ThemeModeOverrides? + public var membersRequireAttention: Int public var id: ChatId { get { "#\(groupId)" } } public var apiId: Int64 { get { groupId } } public var ready: Bool { get { true } } - public var sendMsgEnabled: Bool { get { membership.memberActive } } public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } @@ -1961,23 +2070,27 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { } public var canDelete: Bool { - return membership.memberRole == .owner || !membership.memberCurrent + return membership.memberRole == .owner || !membership.memberCurrentOrPending } public var canAddMembers: Bool { return membership.memberRole >= .admin && membership.memberActive } + public var canModerate: Bool { + return membership.memberRole >= .moderator && membership.memberActive + } + public static let sampleData = GroupInfo( groupId: 1, localDisplayName: "team", groupProfile: GroupProfile.sampleData, fullGroupPreferences: FullGroupPreferences.sampleData, membership: GroupMember.sampleData, - hostConnCustomUserProfileId: nil, chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, + membersRequireAttention: 0, chatTags: [], localAlias: "" ) @@ -1989,12 +2102,20 @@ public struct GroupRef: Decodable, Hashable { } public struct GroupProfile: Codable, NamedChat, Hashable { - public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { + public init( + displayName: String, + fullName: String, + description: String? = nil, + image: String? = nil, + groupPreferences: GroupPreferences? = nil, + memberAdmission: GroupMemberAdmission? = nil + ) { self.displayName = displayName self.fullName = fullName self.description = description self.image = image self.groupPreferences = groupPreferences + self.memberAdmission = memberAdmission } public var displayName: String @@ -2002,14 +2123,48 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var description: String? public var image: String? public var groupPreferences: GroupPreferences? + public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } + public var memberAdmission_: GroupMemberAdmission { + get { self.memberAdmission ?? GroupMemberAdmission() } + set { memberAdmission = newValue } + } + public static let sampleData = GroupProfile( displayName: "team", fullName: "My Team" ) } +public struct GroupMemberAdmission: Codable, Hashable { + public var review: MemberCriteria? + + public init( + review: MemberCriteria? = nil + ) { + self.review = review + } + + public static let sampleData = GroupMemberAdmission( + review: .all + ) +} + +public enum MemberCriteria: String, Codable, Identifiable, Hashable { + case all + + public static var values: [MemberCriteria] { [.all] } + + public var id: Self { self } + + public var text: String { + switch self { + case .all: return NSLocalizedString("all", comment: "member criteria value") + } + } +} + public struct BusinessChatInfo: Decodable, Hashable { public var chatType: BusinessChatType public var businessId: String @@ -2036,6 +2191,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var supportChat: GroupSupportChat? public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } @@ -2087,14 +2243,27 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ? String.localizedStringWithFormat(NSLocalizedString("Past member %@", comment: "past/unknown group member"), name) : name } + + public var localAliasAndFullName: String { + get { + let p = memberProfile + let fullName = p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + let name = p.localAlias == "" ? fullName : "\(p.localAlias) (\(fullName))" + + return pastMember(name) + } + } public var memberActive: Bool { switch memberStatus { + case .memRejected: return false case .memRemoved: return false case .memLeft: return false case .memGroupDeleted: return false case .memUnknown: return false case .memInvited: return false + case .memPendingApproval: return true + case .memPendingReview: return true case .memIntroduced: return false case .memIntroInvited: return false case .memAccepted: return false @@ -2107,11 +2276,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberCurrent: Bool { switch memberStatus { + case .memRejected: return false case .memRemoved: return false case .memLeft: return false case .memGroupDeleted: return false case .memUnknown: return false case .memInvited: return false + case .memPendingApproval: return false + case .memPendingReview: return false case .memIntroduced: return true case .memIntroInvited: return true case .memAccepted: return true @@ -2122,6 +2294,18 @@ public struct GroupMember: Identifiable, Decodable, Hashable { } } + public var memberPending: Bool { + switch memberStatus { + case .memPendingApproval: return true + case .memPendingReview: return true + default: return false + } + } + + public var memberCurrentOrPending: Bool { + memberCurrent || memberPending + } + public func canBeRemoved(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole return memberStatus != .memRemoved && memberStatus != .memLeft @@ -2175,6 +2359,13 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public struct GroupSupportChat: Codable, Hashable { + public var chatTs: Date + public var unread: Int + public var memberAttention: Int + public var mentions: Int +} + public struct GroupMemberSettings: Codable, Hashable { public var showMessages: Bool } @@ -2237,11 +2428,14 @@ public enum GroupMemberCategory: String, Decodable, Hashable { } public enum GroupMemberStatus: String, Decodable, Hashable { + case memRejected = "rejected" case memRemoved = "removed" case memLeft = "left" case memGroupDeleted = "deleted" case memUnknown = "unknown" case memInvited = "invited" + case memPendingApproval = "pending_approval" + case memPendingReview = "pending_review" case memIntroduced = "introduced" case memIntroInvited = "intro-inv" case memAccepted = "accepted" @@ -2252,11 +2446,14 @@ public enum GroupMemberStatus: String, Decodable, Hashable { public var text: LocalizedStringKey { switch self { + case .memRejected: return "rejected" case .memRemoved: return "removed" case .memLeft: return "left" case .memGroupDeleted: return "group deleted" case .memUnknown: return "unknown status" case .memInvited: return "invited" + case .memPendingApproval: return "pending approval" + case .memPendingReview: return "pending review" case .memIntroduced: return "connecting (introduced)" case .memIntroInvited: return "connecting (introduction invitation)" case .memAccepted: return "connecting (accepted)" @@ -2269,11 +2466,14 @@ public enum GroupMemberStatus: String, Decodable, Hashable { public var shortText: LocalizedStringKey { switch self { + case .memRejected: return "rejected" case .memRemoved: return "removed" case .memLeft: return "left" case .memGroupDeleted: return "group deleted" case .memUnknown: return "unknown" case .memInvited: return "invited" + case .memPendingApproval: return "pending" + case .memPendingReview: return "review" case .memIntroduced: return "connecting" case .memIntroInvited: return "connecting" case .memAccepted: return "connecting" @@ -2296,7 +2496,6 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat, Hashable { public var id: ChatId { get { "*\(noteFolderId)" } } public var apiId: Int64 { get { noteFolderId } } public var ready: Bool { get { true } } - public var sendMsgEnabled: Bool { get { true } } public var displayName: String { get { ChatInfo.privateNotesChatName } } public var fullName: String { get { "" } } public var image: String? { get { nil } } @@ -2338,16 +2537,29 @@ public enum ConnectionEntity: Decodable, Hashable { public var id: String? { switch self { - case let .rcvDirectMsgConnection(_, contact): - return contact?.id + case let .rcvDirectMsgConnection(conn, contact): + contact?.id ?? conn.id case let .rcvGroupMsgConnection(_, _, groupMember): - return groupMember.id + groupMember.id case let .userContactConnection(_, userContact): - return userContact.id + userContact.id default: - return nil + nil } } + + // public var localDisplayName: String? { + // switch self { + // case let .rcvDirectMsgConnection(conn, contact): + // if let name = contact?.localDisplayName { "@\(name)" } else { conn.id } + // case let .rcvGroupMsgConnection(_, g, m): + // "#\(g.localDisplayName) @\(m.localDisplayName)" + // case let .userContactConnection(_, userContact): + // userContact.id + // default: + // nil + // } + // } public var conn: Connection { switch self { @@ -2361,10 +2573,11 @@ public enum ConnectionEntity: Decodable, Hashable { } public struct NtfConn: Decodable, Hashable { - public var user_: User? - public var connEntity_: ConnectionEntity? + public var user: User + public var agentConnId: String + public var agentDbQueueId: Int64 + public var connEntity: ConnectionEntity public var expectedMsg_: NtfMsgInfo? - } public struct NtfMsgInfo: Decodable, Hashable { @@ -2372,6 +2585,44 @@ public struct NtfMsgInfo: Decodable, Hashable { public var msgTs: Date } +public enum RcvNtfMsgInfo: Decodable { + case info(ntfMsgInfo: NtfMsgInfo?) + case error(ntfMsgError: AgentErrorType) + + @inline(__always) + public var noMsg: Bool { + if case let .info(msg) = self { msg == nil } else { true } + } + + @inline(__always) + public var isError: Bool { + if case .error = self { true } else { false } + } +} + +let iso8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f +}() + +// used in apiGetConnNtfMessages +public struct ConnMsgReq { + public var msgConnId: String + public var msgDbQueueId: Int64 + public var msgTs: Date // SystemTime encodes as a number, should be taken from NtfMsgInfo + + public init(msgConnId: String, msgDbQueueId: Int64, msgTs: Date) { + self.msgConnId = msgConnId + self.msgDbQueueId = msgDbQueueId + self.msgTs = msgTs + } + + public var cmdString: String { + "\(msgConnId):\(msgDbQueueId):\(iso8601DateFormatter.string(from: msgTs))" + } +} + public struct NtfMsgAckInfo: Decodable, Hashable { public var msgId: String public var msgTs_: Date? @@ -2385,12 +2636,27 @@ public struct ChatItemDeletion: Decodable, Hashable { public struct AChatItem: Decodable, Hashable { public var chatInfo: ChatInfo public var chatItem: ChatItem +} - public var chatId: String { - if case let .groupRcv(groupMember) = chatItem.chatDir { - return groupMember.id - } - return chatInfo.id +public struct CIMentionMember: Decodable, Hashable { + public var groupMemberId: Int64 + public var displayName: String + public var localAlias: String? + public var memberRole: GroupMemberRole +} + +public struct CIMention: Decodable, Hashable { + public var memberId: String + public var memberRef: CIMentionMember? + + public init(groupMember m: GroupMember) { + self.memberId = m.memberId + self.memberRef = CIMentionMember( + groupMemberId: m.groupMemberId, + displayName: m.memberProfile.displayName, + localAlias: m.memberProfile.localAlias, + memberRole: m.memberRole + ) } } @@ -2412,11 +2678,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions self.file = file @@ -2426,6 +2693,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] public var file: CIFile? @@ -2434,7 +2702,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, quotedItem, reactions, file + case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -2487,12 +2755,15 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .userDeleted: nil case .groupDeleted: nil case .memberCreatedContact: nil + case .newMemberPendingReview: nil default: .rcvGroupEvent } case let .sndGroupEvent(event): switch event { case .userRole: nil case .userLeft: nil + case .memberAccepted: nil + case .userPendingReview: nil default: .sndGroupEvent } default: @@ -2525,6 +2796,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { switch rcvGroupEvent { case .groupUpdated: return false case .memberConnected: return false + case .memberAccepted: return false + case .userAccepted: return false case .memberRole: return false case .memberBlocked: return false case .userRole: return true @@ -2536,6 +2809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .invitedViaGroupLink: return false case .memberCreatedContact: return false case .memberProfileUpdated: return false + case .newMemberPendingReview: return true } case .sndGroupEvent: return false case .rcvConnEvent: return false @@ -2604,12 +2878,12 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember?)? { switch (chatInfo, chatDir) { - case let (.group(groupInfo), .groupRcv(groupMember)): + case let (.group(groupInfo, _), .groupRcv(groupMember)): let m = groupInfo.membership return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil ? (groupInfo, groupMember) : nil - case let (.group(groupInfo), .groupSnd): + case let (.group(groupInfo, _), .groupSnd): let m = groupInfo.membership return m.memberRole >= .admin ? (groupInfo, nil) : nil default: return nil @@ -2745,6 +3019,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ), @@ -2767,6 +3042,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ), @@ -2789,6 +3065,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: true, + userMention: false, deletable: false, editable: false ), @@ -2800,7 +3077,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { return item } - public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem { + public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: Data?) -> ChatItem { ChatItem( chatDir: chatDir ?? .directSnd, meta: meta ?? .invalidJSON, @@ -2862,6 +3139,7 @@ public struct CIMeta: Decodable, Hashable { public var itemEdited: Bool public var itemTimed: CITimed? public var itemLive: Bool? + public var userMention: Bool public var deletable: Bool public var editable: Bool @@ -2886,6 +3164,7 @@ public struct CIMeta: Decodable, Hashable { itemDeleted: itemDeleted, itemEdited: itemEdited, itemLive: itemLive, + userMention: false, deletable: deletable, editable: editable ) @@ -2902,6 +3181,7 @@ public struct CIMeta: Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ) @@ -2985,6 +3265,21 @@ public enum CIStatus: Decodable, Hashable { } } + // as in corresponds to SENT response from agent, opposed to `sent` which means snd status + public var isSent: Bool { + switch self { + case .sndNew: false + case .sndSent: true + case .sndRcvd: false + case .sndErrorAuth: true + case .sndError: true + case .sndWarning: true + case .rcvNew: false + case .rcvRead: false + case .invalid: false + } + } + public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? { switch self { case .sndNew: nil @@ -3038,6 +3333,17 @@ public enum CIStatus: Decodable, Hashable { } } +public func shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus) -> Bool { + switch (oldStatus, newStatus) { + case (.sndRcvd, let new) where !new.isSndRcvd: + return true + case (let old, .sndNew) where old.isSent: + return true + default: + return false + } +} + public enum SndError: Decodable, Hashable { case auth case quota @@ -3182,6 +3488,20 @@ public enum CIForwardedFrom: Decodable, Hashable { } } + public var chatTypeApiIdMsgId: (ChatType, Int64, ChatItem.ID?)? { + switch self { + case .unknown: nil + case let .contact(_, _, contactId, msgId): + if let contactId { + (ChatType.direct, contactId, msgId) + } else { nil } + case let .group(_, _, groupId, msgId): + if let groupId { + (ChatType.group, groupId, msgId) + } else { nil } + } + } + public func text(_ chatType: ChatType) -> LocalizedStringKey { chatType == .local ? (chatName == "" ? "saved" : "saved from \(chatName)") @@ -3230,7 +3550,7 @@ public enum CIContent: Decodable, ItemContent, Hashable { case rcvDirectE2EEInfo(e2eeInfo: E2EEInfo) case sndGroupE2EEInfo(e2eeInfo: E2EEInfo) case rcvGroupE2EEInfo(e2eeInfo: E2EEInfo) - case invalidJSON(json: String) + case invalidJSON(json: Data?) public var text: String { get { @@ -3450,8 +3770,12 @@ extension MsgReaction: Decodable { let type = try container.decode(String.self, forKey: CodingKeys.type) switch type { case "emoji": - let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji) - self = .emoji(emoji: emoji) + do { + let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji) + self = .emoji(emoji: emoji) + } catch { + self = .unknown(type: "emoji") + } default: self = .unknown(type: type) } @@ -3725,7 +4049,7 @@ public enum FileError: Decodable, Equatable, Hashable { public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown file chunk address - most likely file is deleted.", comment: "file error text") - case let .blocked(_, info): NSLocalizedString("File is blocked by server operator:\n\(info.reason.text).", comment: "file error text") + case let .blocked(_, info): String.localizedStringWithFormat(NSLocalizedString("File is blocked by server operator:\n%@.", comment: "file error text"), info.reason.text) case .noFile: NSLocalizedString("File not found - most likely file was deleted or cancelled.", comment: "file error text") case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("File server error: %@", comment: "file error text"), srvError.errorInfo) case let .other(fileError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "file error text"), fileError) @@ -3809,7 +4133,7 @@ public enum MsgContent: Equatable, Hashable { } } - var cmdString: String { + public var cmdString: String { "json \(encodeJSON(self))" } @@ -3919,10 +4243,26 @@ extension MsgContent: Encodable { } } +public enum MsgContentTag: String { + case text + case link + case image + case video + case voice + case file + case report +} + public struct FormattedText: Decodable, Hashable { public var text: String public var format: Format? + public static func plain(_ text: String) -> [FormattedText] { + text.isEmpty + ? [] + : [FormattedText(text: text, format: nil)] + } + public var isSecret: Bool { if case .secret = format { true } else { false } } @@ -3937,6 +4277,7 @@ public enum Format: Decodable, Equatable, Hashable { case colored(color: FormatColor) case uri case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) + case mention(memberName: String) case email case phone @@ -3954,12 +4295,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") } } } @@ -3974,18 +4317,16 @@ public enum FormatColor: String, Decodable, Hashable { case black = "black" case white = "white" - public var uiColor: Color { - get { - switch (self) { - case .red: return .red - case .green: return .green - case .blue: return .blue - case .yellow: return .yellow - case .cyan: return .cyan - case .magenta: return .purple - case .black: return .primary - case .white: return .primary - } + public var uiColor: Color? { + switch (self) { + case .red: .red + case .green: .green + case .blue: .blue + case .yellow: .yellow + case .cyan: .cyan + case .magenta: .purple + case .black: nil + case .white: nil } } } @@ -4010,6 +4351,14 @@ public enum ReportReason: Hashable { case let .unknown(type): return type } } + + public var attrString: NSAttributedString { + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + return NSAttributedString(string: text.isEmpty ? self.text : "\(self.text): ", attributes: [ + .font: UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: 0), + .foregroundColor: UIColor(Color.red) + ]) + } } extension ReportReason: Encodable { @@ -4061,9 +4410,61 @@ public enum NtfTknStatus: String, Decodable, Hashable { case new = "NEW" case registered = "REGISTERED" case invalid = "INVALID" + case invalidBad = "INVALID,BAD" + case invalidTopic = "INVALID,TOPIC" + case invalidExpired = "INVALID,EXPIRED" + case invalidUnregistered = "INVALID,UNREGISTERED" case confirmed = "CONFIRMED" case active = "ACTIVE" case expired = "EXPIRED" + + public var workingToken: Bool { + switch self { + case .new: true + case .registered: true + case .invalid: false + case .invalidBad: false + case .invalidTopic: false + case .invalidExpired: false + case .invalidUnregistered: false + case .confirmed: true + case .active: true + case .expired: false + } + } + + public var text: String { + switch self { + case .new: NSLocalizedString("New", comment: "token status text") + case .registered: NSLocalizedString("Registered", comment: "token status text") + case .invalid: NSLocalizedString("Invalid", comment: "token status text") + case .invalidBad: NSLocalizedString("Invalid (bad token)", comment: "token status text") + case .invalidTopic: NSLocalizedString("Invalid (wrong topic)", comment: "token status text") + case .invalidExpired: NSLocalizedString("Invalid (expired)", comment: "token status text") + case .invalidUnregistered: NSLocalizedString("Invalid (unregistered)", comment: "token status text") + case .confirmed: NSLocalizedString("Confirmed", comment: "token status text") + case .active: NSLocalizedString("Active", comment: "token status text") + case .expired: NSLocalizedString("Expired", comment: "token status text") + } + } + + public func info(register: Bool) -> String { + switch self { + case .new: return NSLocalizedString("Please wait for token to be registered.", comment: "token info") + case .registered: fallthrough + case .confirmed: return NSLocalizedString("Please wait for token activation to complete.", comment: "token info") + case .active: return NSLocalizedString("You should receive notifications.", comment: "token info") + case .invalid: fallthrough + case .invalidBad: fallthrough + case .invalidTopic: fallthrough + case .invalidExpired: fallthrough + case .invalidUnregistered: fallthrough + case .expired: + return register + ? NSLocalizedString("Register notification token?", comment: "token info") + : NSLocalizedString("Please try to disable and re-enable notfications.", comment: "token info") + } + } } public struct SndFileTransfer: Decodable, Hashable { @@ -4189,6 +4590,8 @@ public enum RcvDirectEvent: Decodable, Hashable { public enum RcvGroupEvent: Decodable, Hashable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected + case memberAccepted(groupMemberId: Int64, profile: Profile) + case userAccepted case memberLeft case memberRole(groupMemberId: Int64, profile: Profile, role: GroupMemberRole) case memberBlocked(groupMemberId: Int64, profile: Profile, blocked: Bool) @@ -4200,12 +4603,16 @@ public enum RcvGroupEvent: Decodable, Hashable { case invitedViaGroupLink case memberCreatedContact case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) + case newMemberPendingReview var text: String { switch self { case let .memberAdded(_, profile): return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName) case .memberConnected: return NSLocalizedString("member connected", comment: "rcv group event chat item") + case let .memberAccepted(_, profile): + return String.localizedStringWithFormat(NSLocalizedString("accepted %@", comment: "rcv group event chat item"), profile.profileViewName) + case .userAccepted: return NSLocalizedString("accepted you", comment: "rcv group event chat item") case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item") case let .memberRole(_, profile, role): return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text) @@ -4225,6 +4632,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item") case .memberCreatedContact: return NSLocalizedString("connected directly", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) + case .newMemberPendingReview: return NSLocalizedString("New member wants to join the group.", comment: "rcv group event chat item") } } @@ -4249,6 +4657,8 @@ public enum SndGroupEvent: Decodable, Hashable { case memberDeleted(groupMemberId: Int64, profile: Profile) case userLeft case groupUpdated(groupProfile: GroupProfile) + case memberAccepted(groupMemberId: Int64, profile: Profile) + case userPendingReview var text: String { switch self { @@ -4266,6 +4676,9 @@ public enum SndGroupEvent: Decodable, Hashable { return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName) case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item") + case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item") + case .userPendingReview: + return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item") } } } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 2bf861f437..6f80629932 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -16,7 +16,7 @@ public protocol ChatLike { extension ChatLike { public func groupFeatureEnabled(_ feature: GroupFeature) -> Bool { - if case let .group(groupInfo) = self.chatInfo { + if case let .group(groupInfo, _) = self.chatInfo { let p = groupInfo.fullGroupPreferences return switch feature { case .timedMessages: p.timedMessages.on @@ -27,6 +27,7 @@ extension ChatLike { case .files: p.files.on(for: groupInfo.membership) case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) case .history: p.history.on + case .reports: p.reports.on } } else { return true @@ -81,9 +82,9 @@ public func foundChat(_ chat: ChatLike, _ searchStr: String) -> Bool { private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { switch cInfo { - case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv - case let .group(groupInfo): groupInfo.sendMsgEnabled - case let .local(noteFolder): noteFolder.sendMsgEnabled + case let .direct(contact): cInfo.sendMsgEnabled && !contact.nextSendGrpInv + case .group: cInfo.sendMsgEnabled + case .local: cInfo.sendMsgEnabled case .contactRequest: false case .contactConnection: false case .invalidJSON: false @@ -93,7 +94,7 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { public func chatIconName(_ cInfo: ChatInfo) -> String { switch cInfo { case .direct: "person.crop.circle.fill" - case let .group(groupInfo): + case let .group(groupInfo, _): switch groupInfo.businessChat?.chatType { case .none: "person.2.circle.fill" case .business: "briefcase.circle.fill" diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index 0e539ba97c..dfe833f832 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -18,10 +18,10 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { memcpy(ptr, (data as NSData).bytes, data.count) var cPath = path.cString(using: .utf8)! let cjson = chat_write_file(getChatCtrl(), &cPath, ptr, Int32(data.count))! - let d = fromCString(cjson).data(using: .utf8)! + let d = dataFromCString(cjson)! // TODO [unsafe] switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs - case let .error(err): throw RuntimeError(err) + case let .error(err): throw RuntimeError(err) // TODO [unsafe] } } @@ -51,10 +51,10 @@ public func encryptCryptoFile(fromPath: String, toPath: String) throws -> Crypto var cFromPath = fromPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)! let cjson = chat_encrypt_file(getChatCtrl(), &cFromPath, &cToPath)! - let d = fromCString(cjson).data(using: .utf8)! + let d = dataFromCString(cjson)! // TODO [unsafe] switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs - case let .error(err): throw RuntimeError(err) + case let .error(err): throw RuntimeError(err) // TODO [unsafe] } } diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift index 5b9acc4fca..a433d2313b 100644 --- a/apps/ios/SimpleXChat/ErrorAlert.swift +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -37,22 +37,18 @@ public struct ErrorAlert: Error { } public init(_ error: any Error) { - self = if let chatResponse = error as? ChatResponse { - ErrorAlert(chatResponse) + self = if let e = error as? ChatError { + ErrorAlert(e) } else { - ErrorAlert(LocalizedStringKey(error.localizedDescription)) + ErrorAlert("\(error.localizedDescription)") } } public init(_ chatError: ChatError) { - self = ErrorAlert("\(chatErrorString(chatError))") - } - - public init(_ chatResponse: ChatResponse) { - self = if let networkErrorAlert = getNetworkErrorAlert(chatResponse) { + self = if let networkErrorAlert = getNetworkErrorAlert(chatError) { networkErrorAlert } else { - ErrorAlert("\(responseError(chatResponse))") + ErrorAlert("\(chatErrorString(chatError))") } } } @@ -94,22 +90,21 @@ extension View { } } -public func getNetworkErrorAlert(_ r: ChatResponse) -> ErrorAlert? { - switch r { - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): - return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): - return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .HOST))): - return ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TRANSPORT(.version)))): - return ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).") - case let .chatCmdError(_, .errorAgent(.SMP(serverAddress, .PROXY(proxyErr)))): - return smpProxyErrorAlert(proxyErr, serverAddress) - case let .chatCmdError(_, .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr))))): - return proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer) - default: - return nil +public func getNetworkErrorAlert(_ e: ChatError) -> ErrorAlert? { + switch e { + case let .errorAgent(.BROKER(addr, .TIMEOUT)): + ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") + case let .errorAgent(.BROKER(addr, .NETWORK)): + ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") + case let .errorAgent(.BROKER(addr, .HOST)): + ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).") + case let .errorAgent(.BROKER(addr, .TRANSPORT(.version))): + ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).") + case let .errorAgent(.SMP(serverAddress, .PROXY(proxyErr))): + smpProxyErrorAlert(proxyErr, serverAddress) + case let .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr)))): + proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer) + default: nil } } diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index a922e3a816..70db4476d5 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -62,7 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String - if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { + if case let .group(groupInfo, _) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: previewMode == .hidden) } else { title = previewMode == .hidden ? contactHidden : "\(cInfo.chatViewName):" @@ -203,6 +203,11 @@ func hideSecrets(_ cItem: ChatItem) -> String { } return res } else { - return cItem.text + let mc = cItem.content.msgContent + if case let .report(text, reason) = mc { + return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text) + } else { + return cItem.text + } } } diff --git a/apps/ios/SimpleXChat/dummy.m b/apps/ios/SimpleXChat/dummy.m index 64fbc32dd3..d26e108520 100644 --- a/apps/ios/SimpleXChat/dummy.m +++ b/apps/ios/SimpleXChat/dummy.m @@ -21,4 +21,13 @@ DIR *opendir$INODE64(const char *name) { return opendir(name); } +int readdir$INODE64(DIR *restrict dirp, struct dirent *restrict entry, + struct dirent **restrict result) { + return readdir_r(dirp, entry, result); +} + +DIR *fdopendir$INODE64(const char *name) { + return opendir(name); +} + #endif diff --git a/apps/ios/SimpleXChat/hs_init.c b/apps/ios/SimpleXChat/hs_init.c index 4731e7b829..e75173d6cf 100644 --- a/apps/ios/SimpleXChat/hs_init.c +++ b/apps/ios/SimpleXChat/hs_init.c @@ -29,10 +29,10 @@ void haskell_init_nse(void) { char *argv[] = { "simplex", "+RTS", // requires `hs_init_with_rtsopts` - "-A1m", // chunk size for new allocations - "-H1m", // initial heap size + "-A256k", // chunk size for new allocations + "-H512k", // initial heap size "-F0.5", // heap growth triggering GC - "-Fd1", // memory return + "-Fd0.3", // memory return "-c", // compacting garbage collector 0 }; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index f2059d5627..1f4ff88f78 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (може да се копира)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- гласови съобщения до 5 минути.\n- персонализирано време за изчезване.\n- история на редактиране."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 цветно!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(ново)"; /* No comment provided by engineer. */ "(this device v%@)" = "(това устройство v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Допринеси](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -148,6 +127,9 @@ /* notification title */ "%@ wants to connect!" = "%@ иска да се свърже!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@, %@ и %lld членове"; @@ -214,9 +196,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld нови езици на интерфейса"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунда(и)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунди"; @@ -262,7 +241,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 ден"; /* time interval */ @@ -271,12 +251,17 @@ /* No comment provided by engineer. */ "1 minute" = "1 минута"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 месец"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 седмица"; +/* No comment provided by engineer. */ +"1-time link" = "Еднократен линк"; + /* No comment provided by engineer. */ "5 minutes" = "5 минути"; @@ -310,17 +295,26 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Откажи смяна на адрес?"; +/* No comment provided by engineer. */ +"About operators" = "За операторите"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "За SimpleX Chat"; /* No comment provided by engineer. */ "above, then choose:" = "по-горе, след това избери:"; +/* No comment provided by engineer. */ +"Accent" = "Акцент"; + /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Приеми"; +/* No comment provided by engineer. */ +"Accept conditions" = "Приеми условията"; + /* No comment provided by engineer. */ "Accept connection request?" = "Приемане на заявка за връзка?"; @@ -328,15 +322,30 @@ "Accept contact request from %@?" = "Приемане на заявка за контакт от %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Приеми инкогнито"; /* call status */ "accepted call" = "обаждането прието"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Приети условия"; + +/* No comment provided by engineer. */ +"Acknowledged" = "Потвърден"; + +/* No comment provided by engineer. */ +"Acknowledgement errors" = "Грешки при потвърждението"; + +/* No comment provided by engineer. */ +"Active connections" = "Активни връзки"; + /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти."; +/* No comment provided by engineer. */ +"Add friends" = "Добави приятели"; + /* No comment provided by engineer. */ "Add profile" = "Добави профил"; @@ -346,18 +355,45 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Добави сървъри чрез сканиране на QR кодове."; +/* No comment provided by engineer. */ +"Add team members" = "Добави членове на екипа"; + /* No comment provided by engineer. */ "Add to another device" = "Добави към друго устройство"; /* No comment provided by engineer. */ "Add welcome message" = "Добави съобщение при посрещане"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Добавете членовете на вашия екип към разговорите."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Добавени медийни и файлови сървъри"; + +/* No comment provided by engineer. */ +"Added message servers" = "Добавени сървъри за съобщения"; + +/* No comment provided by engineer. */ +"Additional accent" = "Допълнителен акцент"; + +/* No comment provided by engineer. */ +"Additional accent 2" = "Допълнителен акцент 2"; + +/* No comment provided by engineer. */ +"Additional secondary" = "Допълнителен вторичен"; + /* No comment provided by engineer. */ "Address" = "Адрес"; /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Промяната на адреса ще бъде прекъсната. Ще се използва старият адрес за получаване."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Адрес или еднократен линк?"; + +/* No comment provided by engineer. */ +"Address settings" = "Настройки на адреса"; + /* member role */ "admin" = "админ"; @@ -373,6 +409,9 @@ /* No comment provided by engineer. */ "Advanced network settings" = "Разширени мрежови настройки"; +/* No comment provided by engineer. */ +"Advanced settings" = "Разширени настройки"; + /* chat item text */ "agreeing encryption for %@…" = "съгласуване на криптиране за %@…"; @@ -388,6 +427,9 @@ /* No comment provided by engineer. */ "All data is erased when it is entered." = "Всички данни се изтриват при въвеждане."; +/* No comment provided by engineer. */ +"All data is kept private on your device." = "Всички данни се съхраняват поверително на вашето устройство."; + /* No comment provided by engineer. */ "All group members will remain connected." = "Всички членове на групата ще останат свързани."; @@ -403,6 +445,9 @@ /* No comment provided by engineer. */ "All new messages from %@ will be hidden!" = "Всички нови съобщения от %@ ще бъдат скрити!"; +/* profile dropdown */ +"All profiles" = "Всички профили"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Всички ваши контакти ще останат свързани."; @@ -418,9 +463,15 @@ /* No comment provided by engineer. */ "Allow calls only if your contact allows them." = "Позволи обаждания само ако вашият контакт ги разрешава."; +/* No comment provided by engineer. */ +"Allow calls?" = "Позволи обаждания?"; + /* No comment provided by engineer. */ "Allow disappearing messages only if your contact allows it to you." = "Позволи изчезващи съобщения само ако вашият контакт ги разрешава."; +/* No comment provided by engineer. */ +"Allow downgrade" = "Позволи понижаване"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Позволи необратимо изтриване на съобщение само ако вашият контакт го рарешава. (24 часа)"; @@ -436,6 +487,9 @@ /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Разреши изпращането на изчезващи съобщения."; +/* No comment provided by engineer. */ +"Allow sharing" = "Позволи споделяне"; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Позволи необратимо изтриване на изпратените съобщения. (24 часа)"; @@ -481,6 +535,9 @@ /* pref value */ "always" = "винаги"; +/* No comment provided by engineer. */ +"Always use private routing." = "Винаги използвай поверително рутиране."; + /* No comment provided by engineer. */ "Always use relay" = "Винаги използвай реле"; @@ -514,6 +571,9 @@ /* No comment provided by engineer. */ "App passcode is replaced with self-destruct passcode." = "Кода за достъп до приложение се заменя с код за самоунищожение."; +/* No comment provided by engineer. */ +"App session" = "Сесия на приложението"; + /* No comment provided by engineer. */ "App version" = "Версия на приложението"; @@ -526,9 +586,18 @@ /* No comment provided by engineer. */ "Apply" = "Приложи"; +/* No comment provided by engineer. */ +"Apply to" = "Приложи към"; + /* No comment provided by engineer. */ "Archive and upload" = "Архивиране и качване"; +/* No comment provided by engineer. */ +"Archive contacts to chat later." = "Архивирайте контактите, за да разговаряте по-късно."; + +/* No comment provided by engineer. */ +"Archived contacts" = "Архивирани контакти"; + /* No comment provided by engineer. */ "Archiving database" = "Архивиране на база данни"; @@ -574,9 +643,15 @@ /* No comment provided by engineer. */ "Auto-accept images" = "Автоматично приемане на изображения"; +/* alert title */ +"Auto-accept settings" = "Автоматично приемане на настройки"; + /* No comment provided by engineer. */ "Back" = "Назад"; +/* No comment provided by engineer. */ +"Background" = "Фон"; + /* No comment provided by engineer. */ "Bad desktop address" = "Грешен адрес на настолното устройство"; @@ -592,12 +667,33 @@ /* No comment provided by engineer. */ "Bad message ID" = "Лошо ID на съобщението"; +/* No comment provided by engineer. */ +"Better calls" = "По-добри обаждания"; + /* No comment provided by engineer. */ "Better groups" = "По-добри групи"; +/* No comment provided by engineer. */ +"Better message dates." = "По-добри дати на съобщението."; + /* No comment provided by engineer. */ "Better messages" = "По-добри съобщения"; +/* No comment provided by engineer. */ +"Better networking" = "Подобрена мрежа"; + +/* No comment provided by engineer. */ +"Better notifications" = "Подобрени известия"; + +/* No comment provided by engineer. */ +"Better security ✅" = "По-добра сигурност ✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "Подобрен интерфейс"; + +/* No comment provided by engineer. */ +"Black" = "Черна"; + /* No comment provided by engineer. */ "Block" = "Блокирай"; @@ -622,12 +718,19 @@ /* rcv group event chat item */ "blocked %@" = "блокиран %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "блокиран от админ"; /* No comment provided by engineer. */ "Blocked by admin" = "Блокиран от админ"; +/* No comment provided by engineer. */ +"Blur for better privacy." = "Размазване за по-добра поверителност."; + +/* No comment provided by engineer. */ +"Blur media" = "Размазване на медия"; + /* No comment provided by engineer. */ "bold" = "удебелен"; @@ -649,6 +752,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Бизнес адрес"; + +/* No comment provided by engineer. */ +"Business chats" = "Бизнес чатове"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; @@ -667,9 +776,18 @@ /* No comment provided by engineer. */ "Calls" = "Обаждания"; +/* No comment provided by engineer. */ +"Calls prohibited!" = "Обажданията са забранени!"; + /* No comment provided by engineer. */ "Camera not available" = "Камерата е неодстъпна"; +/* No comment provided by engineer. */ +"Can't call contact" = "Обаждането на контакта не е позволено"; + +/* No comment provided by engineer. */ +"Can't call member" = "Обаждането на члена не е позволено"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Не може да покани контакта!"; @@ -677,7 +795,7 @@ "Can't invite contacts!" = "Не може да поканят контактите!"; /* alert action - alert button */ +alert button */ "Cancel" = "Отказ"; /* No comment provided by engineer. */ @@ -692,12 +810,18 @@ /* alert title */ "Cannot receive file" = "Файлът не може да бъде получен"; +/* snd error text */ +"Capacity exceeded - recipient did not receive previously sent messages." = "Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения."; + /* No comment provided by engineer. */ "Cellular" = "Мобилна мрежа"; /* No comment provided by engineer. */ "Change" = "Промени"; +/* authentication reason */ +"Change chat profiles" = "Промени чат профилите"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Промяна на паролата на базата данни?"; @@ -723,7 +847,7 @@ "Change self-destruct mode" = "Промени режима на самоунищожение"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Промени кода за достъп за самоунищожение"; /* chat item text */ @@ -745,13 +869,13 @@ "Chat console" = "Конзола"; /* No comment provided by engineer. */ -"Chat database" = "База данни за чата"; +"Chat database" = "База данни"; /* No comment provided by engineer. */ "Chat database deleted" = "Базата данни на чата е изтрита"; /* No comment provided by engineer. */ -"Chat database imported" = "Базата данни на чат е импортирана"; +"Chat database imported" = "Базата данни на е импортирана"; /* No comment provided by engineer. */ "Chat is running" = "Чатът работи"; @@ -991,16 +1115,19 @@ "Correct name to %@?" = "Поправи име на %@?"; /* No comment provided by engineer. */ -"Create" = "Създай"; +"Create" = "Създаване"; /* No comment provided by engineer. */ -"Create a group using a random profile." = "Създай група с автоматично генериран профилл."; +"Create 1-time link" = "Създаване на еднократна препратка"; + +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Създаване група с автоматично създаден профил."; /* server test step */ -"Create file" = "Създай файл"; +"Create file" = "Създаване на файл"; /* No comment provided by engineer. */ -"Create group" = "Създай група"; +"Create group" = "Създаване на група"; /* No comment provided by engineer. */ "Create group link" = "Създай групов линк"; @@ -1021,7 +1148,7 @@ "Create secret group" = "Създай тайна група"; /* No comment provided by engineer. */ -"Create SimpleX address" = "Създай SimpleX адрес"; +"Create SimpleX address" = "Създаване на адрес в SimpleX"; /* No comment provided by engineer. */ "Create your profile" = "Създай своя профил"; @@ -1125,7 +1252,8 @@ /* message decrypt error item */ "Decryption error" = "Грешка при декриптиране"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "по подразбиране (%@)"; /* No comment provided by engineer. */ @@ -1135,8 +1263,7 @@ "default (yes)" = "по подразбиране (да)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Изтрий"; /* No comment provided by engineer. */ @@ -1211,7 +1338,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Изтрий съобщението?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Изтрий съобщенията"; /* No comment provided by engineer. */ @@ -1248,7 +1375,7 @@ "deleted contact" = "изтрит контакт"; /* rcv group event chat item */ -"deleted group" = "групата изтрита"; +"deleted group" = "групата е изтрита"; /* No comment provided by engineer. */ "Delivery" = "Доставка"; @@ -1358,14 +1485,14 @@ /* No comment provided by engineer. */ "Don't enable" = "Не активирай"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Не показвай отново"; /* No comment provided by engineer. */ "Downgrade and open chat" = "Понижи версията и отвори чата"; /* alert button - chat item action */ +chat item action */ "Download" = "Изтегли"; /* No comment provided by engineer. */ @@ -1404,7 +1531,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Активиране (запазване на промените)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Активиране на автоматично изтриване на съобщения?"; /* No comment provided by engineer. */ @@ -1603,7 +1730,7 @@ "Error decrypting file" = "Грешка при декриптирането на файла"; /* No comment provided by engineer. */ -"Error deleting chat database" = "Грешка при изтриване на чат базата данни"; +"Error deleting chat database" = "Грешка при изтриване на базата данни"; /* No comment provided by engineer. */ "Error deleting chat!" = "Грешка при изтриването на чата!"; @@ -1636,10 +1763,10 @@ "Error encrypting database" = "Грешка при криптиране на базата данни"; /* No comment provided by engineer. */ -"Error exporting chat database" = "Грешка при експортиране на чат базата данни"; +"Error exporting chat database" = "Грешка при експортиране на базата данни"; /* No comment provided by engineer. */ -"Error importing chat database" = "Грешка при импортиране на чат базата данни"; +"Error importing chat database" = "Грешка при импортиране на базата данни"; /* No comment provided by engineer. */ "Error joining group" = "Грешка при присъединяване към група"; @@ -1650,7 +1777,7 @@ /* alert title */ "Error receiving file" = "Грешка при получаване на файл"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Грешка при отстраняване на член"; /* No comment provided by engineer. */ @@ -1719,7 +1846,9 @@ /* No comment provided by engineer. */ "Error: " = "Грешка: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Грешка: %@"; /* No comment provided by engineer. */ @@ -1731,9 +1860,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Дори когато е деактивиран в разговора."; -/* No comment provided by engineer. */ -"event happened" = "събитие се случи"; - /* No comment provided by engineer. */ "Exit without saving" = "Изход без запазване"; @@ -2014,7 +2140,7 @@ "Import" = "Импортиране"; /* No comment provided by engineer. */ -"Import chat database?" = "Импортиране на чат база данни?"; +"Import chat database?" = "Импортиране на база данни?"; /* No comment provided by engineer. */ "Import database" = "Импортиране на база данни"; @@ -2493,7 +2619,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Множество профили за чат"; -/* swipe action */ +/* notification label action */ "Mute" = "Без звук"; /* No comment provided by engineer. */ @@ -2517,7 +2643,7 @@ /* No comment provided by engineer. */ "Network status" = "Състояние на мрежата"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "никога"; /* No comment provided by engineer. */ @@ -2620,8 +2746,9 @@ "observer" = "наблюдател"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "изключено"; /* blur media */ @@ -2633,7 +2760,8 @@ /* feature offered item */ "offered %@: %@" = "предлага %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ок"; /* No comment provided by engineer. */ @@ -2699,7 +2827,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Само вашият контакт може да изпраща гласови съобщения."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Отвори"; /* No comment provided by engineer. */ @@ -3006,7 +3134,7 @@ "Reduced battery usage" = "Намалена консумация на батерията"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Отхвърляне"; /* No comment provided by engineer. */ @@ -3133,7 +3261,7 @@ "Safer groups" = "По-безопасни групи"; /* alert button - chat item action */ +chat item action */ "Save" = "Запази"; /* alert button */ @@ -3389,7 +3517,7 @@ "Shape profile images" = "Променете формата на профилните изображения"; /* alert action - chat item action */ +chat item action */ "Share" = "Сподели"; /* No comment provided by engineer. */ @@ -3650,9 +3778,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Профилът се споделя само с вашите контакти."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Втората отметка, която пропуснахме! ✅"; @@ -3836,7 +3961,7 @@ /* authentication reason */ "Unlock app" = "Отключи приложението"; -/* swipe action */ +/* notification label action */ "Unmute" = "Уведомявай"; /* swipe action */ @@ -4281,10 +4406,10 @@ "Your calls" = "Вашите обаждания"; /* No comment provided by engineer. */ -"Your chat database" = "Вашата чат база данни"; +"Your chat database" = "Вашата база данни"; /* No comment provided by engineer. */ -"Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата чат база данни не е криптирана - задайте парола, за да я криптирате."; +"Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата база данни не е криптирана - задайте парола, за да я криптирате."; /* No comment provided by engineer. */ "Your chat profiles" = "Вашите чат профили"; @@ -4299,7 +4424,7 @@ "Your contacts will remain connected." = "Вашите контакти ще останат свързани."; /* No comment provided by engineer. */ -"Your current chat database will be DELETED and REPLACED with the imported one." = "Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната."; +"Your current chat database will be DELETED and REPLACED with the imported one." = "Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната."; /* No comment provided by engineer. */ "Your current profile" = "Вашият текущ профил"; @@ -4320,10 +4445,10 @@ "Your profile **%@** will be shared." = "Вашият профил **%@** ще бъде споделен."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил."; +"Your profile is stored on your device and only shared with your contacts." = "Профилът се споделя само с вашите контакти."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил."; /* No comment provided by engineer. */ "Your random profile" = "Вашият автоматично генериран профил"; @@ -4335,8 +4460,5 @@ "Your settings" = "Вашите настройки"; /* No comment provided by engineer. */ -"Your SimpleX address" = "Вашият SimpleX адрес"; - -/* No comment provided by engineer. */ -"Your SMP servers" = "Вашите SMP сървъри"; +"Your SimpleX address" = "Вашият адрес в SimpleX"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 837e76ebbf..a3a6ca8215 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (lze kopírovat)"; @@ -19,23 +13,11 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 minutové hlasové zprávy.\n- vlastní čas mizení.\n- historie úprav."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 barevný!"; /* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - -/* No comment provided by engineer. */ -")" = ")"; +"(this device v%@)" = "(toto zařízení v%@)"; /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Přispějte](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -109,6 +91,9 @@ /* No comment provided by engineer. */ "%@ connected" = "%@ připojen"; +/* No comment provided by engineer. */ +"%@ downloaded" = "%@ staženo"; + /* notification title */ "%@ is connected!" = "%@ je připojen!"; @@ -118,9 +103,18 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ je ověřený"; +/* No comment provided by engineer. */ +"%@ server" = "%@ server"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ servery"; + /* notification title */ "%@ wants to connect!" = "%@ se chce připojit!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld other members connected" = "%@, %@ a %lld ostatní členové připojeni"; @@ -130,6 +124,18 @@ /* time interval */ "%d days" = "%d dní"; +/* forward confirmation reason */ +"%d file(s) are still being downloaded." = "%d soubor(y) stále stahován(y)."; + +/* forward confirmation reason */ +"%d file(s) failed to download." = "%d soubor(y) se nepodařilo stáhnout."; + +/* forward confirmation reason */ +"%d file(s) were deleted." = "%d soubor(y) smazán(y)."; + +/* forward confirmation reason */ +"%d file(s) were not downloaded." = "%d soubor(y) nestažen(y)."; + /* time interval */ "%d hours" = "%d hodin"; @@ -163,15 +169,21 @@ /* No comment provided by engineer. */ "%lld members" = "%lld členové"; +/* No comment provided by engineer. */ +"%lld messages blocked" = "%lld zprávy blokovaný"; + +/* No comment provided by engineer. */ +"%lld messages blocked by admin" = "%lld zprávy blokovaný adminem"; + +/* No comment provided by engineer. */ +"%lld messages marked deleted" = "%lld zprávy označeno jako smazáno"; + /* No comment provided by engineer. */ "%lld minutes" = "%lld minut"; /* No comment provided by engineer. */ "%lld new interface languages" = "%d nové jazyky rozhraní"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld vteřin"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld vteřin"; @@ -214,7 +226,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 den"; /* time interval */ @@ -223,10 +236,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minutu"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 měsíc"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 týden"; /* No comment provided by engineer. */ @@ -269,8 +284,8 @@ "above, then choose:" = "výše, pak vyberte:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Přijmout"; /* No comment provided by engineer. */ @@ -280,7 +295,7 @@ "Accept contact request from %@?" = "Přijmout žádost o kontakt od %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Přijmout inkognito"; /* call status */ @@ -545,7 +560,7 @@ "Can't invite contacts!" = "Nelze pozvat kontakty!"; /* alert action - alert button */ +alert button */ "Cancel" = "Zrušit"; /* feature offered item */ @@ -585,7 +600,7 @@ "Change self-destruct mode" = "Změnit režim sebedestrukce"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Změnit sebedestrukční heslo"; /* chat item text */ @@ -807,6 +822,9 @@ /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻"; +/* No comment provided by engineer. */ +"Create profile" = "Vytvořte si profil"; + /* server test step */ "Create queue" = "Vytvořit frontu"; @@ -906,7 +924,8 @@ /* message decrypt error item */ "Decryption error" = "Chyba dešifrování"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "výchozí (%@)"; /* No comment provided by engineer. */ @@ -916,8 +935,7 @@ "default (yes)" = "výchozí (ano)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Smazat"; /* No comment provided by engineer. */ @@ -983,7 +1001,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Smazat zprávu?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Smazat zprávy"; /* No comment provided by engineer. */ @@ -1109,7 +1127,7 @@ /* No comment provided by engineer. */ "Don't enable" = "Nepovolovat"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Znovu neukazuj"; /* No comment provided by engineer. */ @@ -1142,7 +1160,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Povolit (zachovat přepsání)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Povolit automatické mazání zpráv?"; /* No comment provided by engineer. */ @@ -1349,7 +1367,7 @@ /* alert title */ "Error receiving file" = "Chyba při příjmu souboru"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Chyba při odebrání člena"; /* No comment provided by engineer. */ @@ -1406,7 +1424,9 @@ /* No comment provided by engineer. */ "Error: " = "Chyba: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Chyba: %@"; /* No comment provided by engineer. */ @@ -2012,7 +2032,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Více chatovacích profilů"; -/* swipe action */ +/* notification label action */ "Mute" = "Ztlumit"; /* No comment provided by engineer. */ @@ -2030,7 +2050,7 @@ /* No comment provided by engineer. */ "Network status" = "Stav sítě"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nikdy"; /* notification */ @@ -2124,8 +2144,9 @@ "observer" = "pozorovatel"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "vypnuto"; /* blur media */ @@ -2137,7 +2158,8 @@ /* feature offered item */ "offered %@: %@" = "nabídl %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -2200,7 +2222,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Hlasové zprávy může odesílat pouze váš kontakt."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Otevřít"; /* No comment provided by engineer. */ @@ -2432,7 +2454,7 @@ "Reduced battery usage" = "Snížení spotřeby baterie"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Odmítnout"; /* No comment provided by engineer. */ @@ -2532,7 +2554,7 @@ "Run chat" = "Spustit chat"; /* alert button - chat item action */ +chat item action */ "Save" = "Uložit"; /* alert button */ @@ -2746,7 +2768,7 @@ "Settings" = "Nastavení"; /* alert action - chat item action */ +chat item action */ "Share" = "Sdílet"; /* No comment provided by engineer. */ @@ -2968,9 +2990,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stará databáze nebyla během přenášení odstraněna, lze ji smazat."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Profil je sdílen pouze s vašimi kontakty."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Druhé zaškrtnutí jsme přehlédli! ✅"; @@ -3100,7 +3119,7 @@ /* authentication reason */ "Unlock app" = "Odemknout aplikaci"; -/* swipe action */ +/* notification label action */ "Unmute" = "Zrušit ztlumení"; /* swipe action */ @@ -3452,10 +3471,10 @@ "Your profile **%@** will be shared." = "Váš profil **%@** bude sdílen."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty. Servery SimpleX nevidí váš profil."; +"Your profile is stored on your device and only shared with your contacts." = "Profil je sdílen pouze s vašimi kontakty."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty. Servery SimpleX nevidí váš profil."; /* No comment provided by engineer. */ "Your random profile" = "Váš náhodný profil"; @@ -3469,6 +3488,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Vaše SimpleX adresa"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Vaše servery SMP"; - diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index cad89ed29a..4be4ad96ba 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (kann kopiert werden)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- Bis zu 5 Minuten lange Sprachnachrichten\n- Zeitdauer für verschwindende Nachrichten anpassen\n- Nachrichtenverlauf bearbeiten"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 farbig!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(Neu)"; /* No comment provided by engineer. */ "(this device v%@)" = "(Dieses Gerät hat v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -199,6 +178,9 @@ /* time interval */ "%d sec" = "%d s"; +/* delete after time */ +"%d seconds(s)" = "%d Sekunde(n)"; + /* integrity error chat item */ "%d skipped message(s)" = "%d übersprungene Nachricht(en)"; @@ -241,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld neue Sprachen für die Bedienoberfläche"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld Sekunde(n)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld Sekunden"; @@ -289,8 +268,9 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ -"1 day" = "täglich"; +/* delete after time +time interval */ +"1 day" = "Älter als ein Tag"; /* time interval */ "1 hour" = "1 Stunde"; @@ -298,11 +278,16 @@ /* No comment provided by engineer. */ "1 minute" = "1 Minute"; -/* time interval */ -"1 month" = "monatlich"; +/* delete after time +time interval */ +"1 month" = "Älter als ein Monat"; -/* time interval */ -"1 week" = "wöchentlich"; +/* delete after time +time interval */ +"1 week" = "Älter als eine Woche"; + +/* delete after time */ +"1 year" = "Älter als ein Jahr"; /* No comment provided by engineer. */ "1-time link" = "Einmal-Link"; @@ -344,7 +329,7 @@ "Abort changing address?" = "Wechsel der Empfängeradresse beenden?"; /* No comment provided by engineer. */ -"About operators" = "Über Betreiber"; +"About operators" = "Über die Betreiber"; /* No comment provided by engineer. */ "About SimpleX Chat" = "Über SimpleX Chat"; @@ -356,10 +341,16 @@ "Accent" = "Akzent"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Annehmen"; +/* alert action */ +"Accept as member" = "Als Mitglied übernehmen"; + +/* alert action */ +"Accept as observer" = "Als Beobachter übernehmen"; + /* No comment provided by engineer. */ "Accept conditions" = "Nutzungsbedingungen akzeptieren"; @@ -370,9 +361,15 @@ "Accept contact request from %@?" = "Die Kontaktanfrage von %@ annehmen?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Inkognito akzeptieren"; +/* alert title */ +"Accept member" = "Mitglied übernehmen"; + +/* rcv group event chat item */ +"accepted %@" = "%@ übernommen"; + /* call status */ "accepted call" = "Anruf angenommen"; @@ -380,7 +377,10 @@ "Accepted conditions" = "Akzeptierte Nutzungsbedingungen"; /* chat list item title */ -"accepted invitation" = "Einladung akzeptiert"; +"accepted invitation" = "Einladung angenommen"; + +/* rcv group event chat item */ +"accepted you" = "hat Sie übernommen"; /* No comment provided by engineer. */ "Acknowledged" = "Bestätigt"; @@ -388,6 +388,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Fehler bei der Bestätigung"; +/* token status text */ +"Active" = "Aktiv"; + /* No comment provided by engineer. */ "Active connections" = "Aktive Verbindungen"; @@ -397,14 +400,17 @@ /* No comment provided by engineer. */ "Add friends" = "Freunde aufnehmen"; +/* No comment provided by engineer. */ +"Add list" = "Liste hinzufügen"; + /* No comment provided by engineer. */ "Add profile" = "Profil hinzufügen"; /* No comment provided by engineer. */ -"Add server" = "Füge Server hinzu"; +"Add server" = "Server hinzufügen"; /* No comment provided by engineer. */ -"Add servers by scanning QR codes." = "Fügen Sie Server durch Scannen der QR Codes hinzu."; +"Add servers by scanning QR codes." = "Server durch Scannen von QR Codes hinzufügen."; /* No comment provided by engineer. */ "Add team members" = "Team-Mitglieder aufnehmen"; @@ -412,6 +418,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Einem anderen Gerät hinzufügen"; +/* No comment provided by engineer. */ +"Add to list" = "Zur Liste hinzufügen"; + /* No comment provided by engineer. */ "Add welcome message" = "Begrüßungsmeldung hinzufügen"; @@ -469,12 +478,21 @@ /* chat item text */ "agreeing encryption…" = "Verschlüsselung zustimmen…"; +/* member criteria value */ +"all" = "alle"; + +/* No comment provided by engineer. */ +"All" = "Alle"; + /* No comment provided by engineer. */ "All app data is deleted." = "Werden die App-Daten komplett gelöscht."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Alle Chats werden von der Liste %@ entfernt und danach wird die Liste gelöscht."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Alle Daten werden gelöscht, sobald dieser eingegeben wird."; @@ -502,6 +520,12 @@ /* profile dropdown */ "All profiles" = "Alle Profile"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Alle Meldungen werden für Sie archiviert."; + +/* No comment provided by engineer. */ +"All servers" = "Alle Server"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Alle Ihre Kontakte bleiben verbunden."; @@ -547,6 +571,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Melden von Nachrichten an Moderatoren erlauben."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Das Senden von Dateien und Medien erlauben."; @@ -601,6 +628,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "und %lld weitere Ereignisse"; +/* report reason */ +"Another reason" = "Anderer Grund"; + /* No comment provided by engineer. */ "Answer call" = "Anruf annehmen"; @@ -616,6 +646,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt."; +/* No comment provided by engineer. */ +"App group:" = "App-Gruppe:"; + /* No comment provided by engineer. */ "App icon" = "App-Icon"; @@ -643,15 +676,36 @@ /* No comment provided by engineer. */ "Apply to" = "Anwenden auf"; +/* No comment provided by engineer. */ +"Archive" = "Archiv"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiviere %lld Meldungen?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Alle Meldungen archivieren?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivieren und Hochladen"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Kontakte für spätere Chats archivieren."; +/* No comment provided by engineer. */ +"Archive report" = "Meldung archivieren"; + +/* No comment provided by engineer. */ +"Archive report?" = "Meldung archivieren?"; + +/* swipe action */ +"Archive reports" = "Meldungen archivieren"; + /* No comment provided by engineer. */ "Archived contacts" = "Archivierte Kontakte"; +/* No comment provided by engineer. */ +"archived report" = "Archivierte Meldung"; + /* No comment provided by engineer. */ "Archiving database" = "Datenbank wird archiviert"; @@ -730,6 +784,9 @@ /* No comment provided by engineer. */ "Better groups" = "Bessere Gruppen"; +/* No comment provided by engineer. */ +"Better groups performance" = "Bessere Leistung von Gruppen"; + /* No comment provided by engineer. */ "Better message dates." = "Verbesserte Nachrichten-Datumsinformation"; @@ -742,6 +799,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Verbesserte Benachrichtigungen"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Bessere(r) Security und Datenschutz"; + /* No comment provided by engineer. */ "Better security ✅" = "Verbesserte Sicherheit ✅"; @@ -775,7 +835,8 @@ /* rcv group event chat item */ "blocked %@" = "%@ wurde blockiert"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "wurde vom Administrator blockiert"; /* No comment provided by engineer. */ @@ -785,7 +846,7 @@ "Blur for better privacy." = "Für bessere Privatsphäre verpixeln."; /* No comment provided by engineer. */ -"Blur media" = "Medium unscharf machen"; +"Blur media" = "Medium verpixeln"; /* No comment provided by engineer. */ "bold" = "fett"; @@ -814,9 +875,15 @@ /* No comment provided by engineer. */ "Business chats" = "Geschäftliche Chats"; +/* No comment provided by engineer. */ +"Businesses" = "Unternehmen"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam."; + /* No comment provided by engineer. */ "call" = "Anrufen"; @@ -856,8 +923,11 @@ /* No comment provided by engineer. */ "Can't message member" = "Mitglied kann nicht benachrichtigt werden"; +/* No comment provided by engineer. */ +"can't send messages" = "Es können keine Nachrichten gesendet werden"; + /* alert action - alert button */ +alert button */ "Cancel" = "Abbrechen"; /* No comment provided by engineer. */ @@ -884,6 +954,9 @@ /* No comment provided by engineer. */ "Change" = "Ändern"; +/* alert title */ +"Change automatic message deletion?" = "Automatisches Löschen von Nachrichten ändern?"; + /* authentication reason */ "Change chat profiles" = "Chat-Profile wechseln"; @@ -912,7 +985,7 @@ "Change self-destruct mode" = "Selbstzerstörungs-Modus ändern"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Selbstzerstörungs-Zugangscode ändern"; /* chat item text */ @@ -990,9 +1063,18 @@ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Der Chat wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden!"; +/* chat toolbar */ +"Chat with admins" = "Chat mit Administratoren"; + +/* No comment provided by engineer. */ +"Chat with member" = "Chat mit einem Mitglied"; + /* No comment provided by engineer. */ "Chats" = "Chats"; +/* No comment provided by engineer. */ +"Chats with members" = "Chats mit Mitgliedern"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Alle 20min Nachrichten überprüfen."; @@ -1032,6 +1114,12 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Chat-Inhalte entfernen?"; +/* No comment provided by engineer. */ +"Clear group?" = "Gruppe entfernen?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Gruppe entfernen oder löschen?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Private Notizen entfernen?"; @@ -1047,6 +1135,9 @@ /* No comment provided by engineer. */ "colored" = "farbig"; +/* report reason */ +"Community guidelines violation" = "Verstoß gegen die Gemeinschaftsrichtlinien"; + /* server test step */ "Compare file" = "Datei vergleichen"; @@ -1071,12 +1162,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Nutzungsbedingungen"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Die Nutzungsbedingungen der aktivierten Betreiber werden nach 30 Tagen akzeptiert."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**."; @@ -1089,6 +1174,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-Server konfigurieren"; +/* No comment provided by engineer. */ +"Configure server operators" = "Server-Betreiber konfigurieren"; + /* No comment provided by engineer. */ "Confirm" = "Bestätigen"; @@ -1119,6 +1207,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Hochladen bestätigen"; +/* token status text */ +"Confirmed" = "Bestätigt"; + /* server test step */ "Connect" = "Verbinden"; @@ -1218,6 +1309,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Verbindungs- und Server-Status."; +/* No comment provided by engineer. */ +"Connection blocked" = "Verbindung blockiert"; + /* No comment provided by engineer. */ "Connection error" = "Verbindungsfehler"; @@ -1227,12 +1321,21 @@ /* chat list item title (it should not be shown */ "connection established" = "Verbindung hergestellt"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Server-Betreiber blockiert:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Verbindung noch nicht bereit."; + /* No comment provided by engineer. */ "Connection notifications" = "Verbindungsbenachrichtigungen"; /* No comment provided by engineer. */ "Connection request sent!" = "Verbindungsanfrage wurde gesendet!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Die Verbindung erfordert eine Neuverhandlung der Verschlüsselung."; + /* No comment provided by engineer. */ "Connection security" = "Verbindungs-Sicherheit"; @@ -1260,9 +1363,15 @@ /* No comment provided by engineer. */ "Contact already exists" = "Der Kontakt ist bereits vorhanden"; +/* No comment provided by engineer. */ +"contact deleted" = "Kontakt gelöscht"; + /* No comment provided by engineer. */ "Contact deleted!" = "Kontakt gelöscht!"; +/* No comment provided by engineer. */ +"contact disabled" = "Kontakt deaktiviert"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "Kontakt nutzt E2E-Verschlüsselung"; @@ -1281,6 +1390,9 @@ /* No comment provided by engineer. */ "Contact name" = "Kontaktname"; +/* No comment provided by engineer. */ +"contact not ready" = "Kontakt nicht bereit"; + /* No comment provided by engineer. */ "Contact preferences" = "Kontakt-Präferenzen"; @@ -1293,6 +1405,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen."; +/* blocking reason */ +"Content violates conditions of use" = "Inhalt verletzt Nutzungsbedingungen"; + /* No comment provided by engineer. */ "Continue" = "Weiter"; @@ -1335,6 +1450,9 @@ /* No comment provided by engineer. */ "Create link" = "Link erzeugen"; +/* No comment provided by engineer. */ +"Create list" = "Liste erstellen"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻"; @@ -1381,7 +1499,7 @@ "Current passphrase…" = "Aktuelles Passwort…"; /* No comment provided by engineer. */ -"Current profile" = "Aktueller Profil"; +"Current profile" = "Aktuelles Profil"; /* No comment provided by engineer. */ "Currently maximum supported file size is %@." = "Die derzeit maximal unterstützte Dateigröße beträgt %@."; @@ -1476,8 +1594,9 @@ /* No comment provided by engineer. */ "decryption errors" = "Entschlüsselungs-Fehler"; -/* pref value */ -"default (%@)" = "Voreinstellung (%@)"; +/* delete after time +pref value */ +"default (%@)" = "Default (%@)"; /* No comment provided by engineer. */ "default (no)" = "Voreinstellung (Nein)"; @@ -1486,8 +1605,7 @@ "default (yes)" = "Voreinstellung (Ja)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Löschen"; /* No comment provided by engineer. */ @@ -1514,12 +1632,18 @@ /* No comment provided by engineer. */ "Delete chat" = "Chat löschen"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Chat-Nachrichten von Ihrem Gerät löschen."; + /* No comment provided by engineer. */ "Delete chat profile" = "Chat-Profil löschen"; /* No comment provided by engineer. */ "Delete chat profile?" = "Chat-Profil löschen?"; +/* alert title */ +"Delete chat with member?" = "Chat mit dem Mitglied löschen?"; + /* No comment provided by engineer. */ "Delete chat?" = "Chat löschen?"; @@ -1568,17 +1692,20 @@ /* No comment provided by engineer. */ "Delete link?" = "Link löschen?"; +/* alert title */ +"Delete list?" = "Liste löschen?"; + /* No comment provided by engineer. */ "Delete member message?" = "Nachricht des Mitglieds löschen?"; /* No comment provided by engineer. */ "Delete message?" = "Die Nachricht löschen?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Nachrichten löschen"; /* No comment provided by engineer. */ -"Delete messages after" = "Löschen der Nachrichten"; +"Delete messages after" = "Nachrichten löschen"; /* No comment provided by engineer. */ "Delete old database" = "Alte Datenbank löschen"; @@ -1598,6 +1725,9 @@ /* server test step */ "Delete queue" = "Lösche Warteschlange"; +/* No comment provided by engineer. */ +"Delete report" = "Meldung löschen"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Löschen Sie bis zu 20 Nachrichten auf einmal."; @@ -1706,6 +1836,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Deaktivieren (vorgenommene Einstellungen bleiben erhalten)"; +/* alert title */ +"Disable automatic message deletion?" = "Automatisches Löschen von Nachrichten deaktivieren?"; + +/* alert button */ +"Disable delete messages" = "Löschen von Nachrichten deaktivieren"; + /* No comment provided by engineer. */ "Disable for all" = "Für Alle deaktivieren"; @@ -1766,6 +1902,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "SimpleX NICHT für Notrufe nutzen."; +/* No comment provided by engineer. */ +"Documents:" = "Dokumente:"; + /* No comment provided by engineer. */ "Don't create address" = "Keine Adresse erstellt"; @@ -1773,13 +1912,19 @@ "Don't enable" = "Nicht aktivieren"; /* No comment provided by engineer. */ +"Don't miss important messages." = "Verpassen Sie keine wichtigen Nachrichten."; + +/* alert action */ "Don't show again" = "Nicht nochmals anzeigen"; +/* No comment provided by engineer. */ +"Done" = "Fertig"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Datenbank herabstufen und den Chat öffnen"; /* alert button - chat item action */ +chat item action */ "Download" = "Herunterladen"; /* No comment provided by engineer. */ @@ -1836,14 +1981,14 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Aktivieren (vorgenommene Einstellungen bleiben erhalten)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatisches Löschen von Nachrichten aktivieren?"; /* No comment provided by engineer. */ "Enable camera access" = "Kamera-Zugriff aktivieren"; /* No comment provided by engineer. */ -"Enable Flux" = "Flux aktivieren"; +"Enable Flux in Network & servers settings for better metadata privacy." = "Für einen besseren Metadatenschutz Flux in den Netzwerk- und Servereinstellungen aktivieren."; /* No comment provided by engineer. */ "Enable for all" = "Für Alle aktivieren"; @@ -1956,6 +2101,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "Neuaushandlung der Verschlüsselung von %@ notwendig"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Die Neuverhandlung der Verschlüsselung läuft."; + /* No comment provided by engineer. */ "ended" = "beendet"; @@ -2010,6 +2158,9 @@ /* No comment provided by engineer. */ "Error accepting contact request" = "Fehler beim Annehmen der Kontaktanfrage"; +/* alert title */ +"Error accepting member" = "Fehler beim Übernehmen des Mitglieds"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Fehler beim Hinzufügen von Mitgliedern"; @@ -2031,6 +2182,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Fehler beim Wechseln zum Inkognito-Profil!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Fehler beim Überprüfen des Token-Status"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut."; @@ -2043,6 +2197,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fehler beim Erzeugen des Gruppen-Links"; +/* alert title */ +"Error creating list" = "Fehler beim Erstellen der Liste"; + /* No comment provided by engineer. */ "Error creating member contact" = "Fehler beim Anlegen eines Mitglied-Kontaktes"; @@ -2052,12 +2209,18 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Fehler beim Erstellen des Profils!"; +/* No comment provided by engineer. */ +"Error creating report" = "Fehler beim Erstellen der Meldung"; + /* No comment provided by engineer. */ "Error decrypting file" = "Fehler beim Entschlüsseln der Datei"; /* No comment provided by engineer. */ "Error deleting chat database" = "Fehler beim Löschen der Chat-Datenbank"; +/* alert title */ +"Error deleting chat with member" = "Fehler beim Löschen des Chats mit dem Mitglied"; + /* No comment provided by engineer. */ "Error deleting chat!" = "Fehler beim Löschen des Chats!"; @@ -2110,7 +2273,7 @@ "Error opening chat" = "Fehler beim Öffnen des Chats"; /* alert title */ -"Error receiving file" = "Fehler beim Empfangen der Datei"; +"Error receiving file" = "Fehler beim Herunterladen der Datei"; /* No comment provided by engineer. */ "Error reconnecting server" = "Fehler beim Wiederherstellen der Verbindung zum Server"; @@ -2118,12 +2281,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Fehler beim Wiederherstellen der Verbindungen zu den Servern"; -/* No comment provided by engineer. */ +/* alert title */ +"Error registering for notifications" = "Fehler beim Registrieren für Benachrichtigungen"; + +/* alert title */ "Error removing member" = "Fehler beim Entfernen des Mitglieds"; +/* alert title */ +"Error reordering lists" = "Fehler beim Umsortieren der Listen"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Fehler beim Zurücksetzen der Statistiken"; +/* alert title */ +"Error saving chat list" = "Fehler beim Speichern der Chat-Liste"; + /* No comment provided by engineer. */ "Error saving group profile" = "Fehler beim Speichern des Gruppenprofils"; @@ -2175,6 +2347,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Fehler beim Synchronisieren der Verbindung"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Fehler beim Testen der Server-Verbindung"; + /* No comment provided by engineer. */ "Error updating group link" = "Fehler beim Aktualisieren des Gruppen-Links"; @@ -2199,7 +2374,9 @@ /* No comment provided by engineer. */ "Error: " = "Fehler: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Fehler: %@"; /* No comment provided by engineer. */ @@ -2217,9 +2394,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Auch wenn sie im Chat deaktiviert sind."; -/* No comment provided by engineer. */ -"event happened" = "event happened"; - /* No comment provided by engineer. */ "Exit without saving" = "Beenden ohne Speichern"; @@ -2229,6 +2403,9 @@ /* No comment provided by engineer. */ "expired" = "Abgelaufen"; +/* token status text */ +"Expired" = "Abgelaufen"; + /* No comment provided by engineer. */ "Export database" = "Datenbank exportieren"; @@ -2253,18 +2430,30 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Schnell und ohne warten auf den Absender, bis er online ist!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Schnelleres löschen von Gruppen."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Schnelleres versenden von Nachrichten."; + /* swipe action */ "Favorite" = "Favorit"; /* No comment provided by engineer. */ +"Favorites" = "Favoriten"; + +/* file error alert title */ "File error" = "Datei-Fehler"; /* alert message */ "File errors:\n%@" = "Datei-Fehler:\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Datei wurde vom Server-Betreiber blockiert:\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen."; @@ -2281,10 +2470,10 @@ "File will be deleted from servers." = "Die Datei wird von den Servern gelöscht."; /* No comment provided by engineer. */ -"File will be received when your contact completes uploading it." = "Die Datei wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; +"File will be received when your contact completes uploading it." = "Die Datei wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; /* No comment provided by engineer. */ -"File will be received when your contact is online, please wait or check later!" = "Die Datei wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; +"File will be received when your contact is online, please wait or check later!" = "Die Datei wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; /* No comment provided by engineer. */ "File: %@" = "Datei: %@"; @@ -2341,7 +2530,7 @@ "Fix not supported by group member" = "Reparatur wird vom Gruppenmitglied nicht unterstützt"; /* No comment provided by engineer. */ -"for better metadata privacy." = "für einen besseren Metadatenschutz."; +"For all moderators" = "Für alle Moderatoren"; /* servers error */ "For chat profile %@:" = "Für das Chat-Profil %@:"; @@ -2352,6 +2541,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Wenn Ihr Kontakt beispielsweise Nachrichten über einen SimpleX-Chatserver empfängt, wird Ihre App diese über einen der Server von Flux versenden."; +/* No comment provided by engineer. */ +"For me" = "Für mich"; + /* No comment provided by engineer. */ "For private routing" = "Für privates Routing"; @@ -2424,6 +2616,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Weiter reduzierter Batterieverbrauch"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Bei Erwähnung benachrichtigt werden."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIFs und Sticker"; @@ -2463,6 +2658,9 @@ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Die Gruppeneinladung ist nicht mehr gültig, da sie vom Absender entfernt wurde."; +/* No comment provided by engineer. */ +"group is deleted" = "Gruppe wird gelöscht"; + /* No comment provided by engineer. */ "Group link" = "Gruppen-Link"; @@ -2496,9 +2694,15 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Die Gruppe wird nur bei Ihnen gelöscht. Dies kann nicht rückgängig gemacht werden!"; +/* No comment provided by engineer. */ +"Groups" = "Gruppen"; + /* No comment provided by engineer. */ "Help" = "Hilfe"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Helfen Sie Administratoren bei der Moderation ihrer Gruppen."; + /* No comment provided by engineer. */ "Hidden" = "Verborgen"; @@ -2535,6 +2739,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Wie es die Privatsphäre schützt"; +/* alert button */ +"How it works" = "Wie es funktioniert"; + /* No comment provided by engineer. */ "How SimpleX works" = "Wie SimpleX funktioniert"; @@ -2569,10 +2776,10 @@ "Ignore" = "Ignorieren"; /* No comment provided by engineer. */ -"Image will be received when your contact completes uploading it." = "Das Bild wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; +"Image will be received when your contact completes uploading it." = "Das Bild wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; /* No comment provided by engineer. */ -"Image will be received when your contact is online, please wait or check later!" = "Das Bild wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; +"Image will be received when your contact is online, please wait or check later!" = "Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; /* No comment provided by engineer. */ "Immediately" = "Sofort"; @@ -2622,6 +2829,12 @@ /* No comment provided by engineer. */ "inactive" = "Inaktiv"; +/* report reason */ +"Inappropriate content" = "Unangemessener Inhalt"; + +/* report reason */ +"Inappropriate profile" = "Unangemessenes Profil"; + /* No comment provided by engineer. */ "Incognito" = "Inkognito"; @@ -2688,6 +2901,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Interface-Farben"; +/* token status text */ +"Invalid" = "Ungültig"; + +/* token status text */ +"Invalid (bad token)" = "Ungültig (falsches Token)"; + +/* token status text */ +"Invalid (expired)" = "Ungültig (abgelaufen)"; + +/* token status text */ +"Invalid (unregistered)" = "Ungültig (nicht registriert)"; + +/* token status text */ +"Invalid (wrong topic)" = "Ungültig (falsches Thema)"; + /* invalid chat data */ "invalid chat" = "Ungültiger Chat"; @@ -2889,6 +3117,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Verknüpfte Desktops"; +/* swipe action */ +"List" = "Liste"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Der Listenname und das Emoji sollen für alle Listen unterschiedlich sein."; + +/* No comment provided by engineer. */ +"List name..." = "Listenname..."; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2952,12 +3189,21 @@ /* profile update event chat item */ "member %@ changed to %@" = "Der Mitgliedsname von %1$@ wurde auf %2$@ geändert"; +/* No comment provided by engineer. */ +"Member admission" = "Aufnahme von Mitgliedern"; + /* rcv group event chat item */ "member connected" = "ist der Gruppe beigetreten"; +/* No comment provided by engineer. */ +"member has old version" = "Das Mitglied hat eine alte App-Version"; + /* item status text */ "Member inactive" = "Mitglied inaktiv"; +/* chat feature */ +"Member reports" = "Mitglieder-Meldungen"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "Die Rolle des Mitglieds wird auf \"%@\" geändert. Alle Chat-Mitglieder werden darüber informiert."; @@ -2973,27 +3219,36 @@ /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden!"; +/* alert message */ +"Member will join the group, accept member?" = "Ein Mitglied wird der Gruppe beitreten. Übernehmen?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "Gruppenmitglieder können eine Reaktion auf Nachrichten geben."; /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Mitglieder können Nachrichten an Moderatoren melden."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Gruppenmitglieder können Direktnachrichten versenden."; /* No comment provided by engineer. */ -"Members can send disappearing messages." = "Gruppenmitglieder können verschwindende Nachrichten senden."; +"Members can send disappearing messages." = "Gruppenmitglieder können verschwindende Nachrichten versenden."; /* No comment provided by engineer. */ -"Members can send files and media." = "Gruppenmitglieder können Dateien und Medien senden."; +"Members can send files and media." = "Gruppenmitglieder können Dateien und Medien versenden."; /* No comment provided by engineer. */ -"Members can send SimpleX links." = "Gruppenmitglieder können SimpleX-Links senden."; +"Members can send SimpleX links." = "Gruppenmitglieder können SimpleX-Links versenden."; /* No comment provided by engineer. */ "Members can send voice messages." = "Gruppenmitglieder können Sprachnachrichten versenden."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Erwähnung von Mitgliedern 👋"; + /* No comment provided by engineer. */ "Menus" = "Menüs"; @@ -3066,6 +3321,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Die Nachrichten von %@ werden angezeigt!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Nachrichten in diesem Chat werden nie gelöscht."; + /* No comment provided by engineer. */ "Messages received" = "Empfangene Nachrichten"; @@ -3076,10 +3334,10 @@ "Messages were deleted after you selected them." = "Die Nachrichten wurden gelöscht, nachdem Sie sie ausgewählt hatten."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt."; +"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt."; +"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt."; /* No comment provided by engineer. */ "Migrate device" = "Gerät migrieren"; @@ -3138,9 +3396,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "Von %@ moderiert"; +/* member role */ +"moderator" = "Moderator"; + /* time unit */ "months" = "Monate"; +/* swipe action */ +"More" = "Mehr"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Weitere Verbesserungen sind bald verfügbar!"; @@ -3156,12 +3420,12 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Mehrere Chat-Profile"; -/* No comment provided by engineer. */ -"mute" = "Stummschalten"; - -/* swipe action */ +/* notification label action */ "Mute" = "Stummschalten"; +/* notification label action */ +"Mute all" = "Alle stummschalten"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Bei Inaktivität stummgeschaltet!"; @@ -3192,9 +3456,12 @@ /* No comment provided by engineer. */ "Network status" = "Netzwerkstatus"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nie"; +/* token status text */ +"New" = "Neu"; + /* No comment provided by engineer. */ "New chat" = "Neuer Chat"; @@ -3225,6 +3492,9 @@ /* No comment provided by engineer. */ "New member role" = "Neue Mitgliedsrolle"; +/* rcv group event chat item */ +"New member wants to join the group." = "Ein neues Mitglied will der Gruppe beitreten."; + /* notification */ "new message" = "Neue Nachricht"; @@ -3255,6 +3525,18 @@ /* Authentication unavailable */ "No app password" = "Kein App-Passwort"; +/* No comment provided by engineer. */ +"No chats" = "Keine Chats"; + +/* No comment provided by engineer. */ +"No chats found" = "Keine Chats gefunden"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Keine Chats in der Liste %@"; + +/* No comment provided by engineer. */ +"No chats with members" = "Keine Chats mit Mitgliedern"; + /* No comment provided by engineer. */ "No contacts selected" = "Keine Kontakte ausgewählt"; @@ -3288,6 +3570,9 @@ /* servers error */ "No media & file servers." = "Keine Medien- und Dateiserver."; +/* No comment provided by engineer. */ +"No message" = "Keine Nachricht"; + /* servers error */ "No message servers." = "Keine Nachrichten-Server."; @@ -3307,13 +3592,13 @@ "No push server" = "Lokal"; /* No comment provided by engineer. */ -"No received or sent files" = "Keine empfangenen oder gesendeten Dateien"; +"No received or sent files" = "Keine herunter- oder hochgeladenen Dateien"; /* servers error */ "No servers for private message routing." = "Keine Server für privates Nachrichten-Routing."; /* servers error */ -"No servers to receive files." = "Keine Server für den Empfang von Dateien."; +"No servers to receive files." = "Keine Server für das Herunterladen von Dateien."; /* servers error */ "No servers to receive messages." = "Keine Server für den Empfang von Nachrichten."; @@ -3324,12 +3609,24 @@ /* copied message info in history */ "no text" = "Kein Text"; +/* alert title */ +"No token!" = "Kein Token!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Keine ungelesenen Chats"; + /* No comment provided by engineer. */ "No user identifiers." = "Keine Benutzerkennungen."; /* No comment provided by engineer. */ "Not compatible!" = "Nicht kompatibel!"; +/* No comment provided by engineer. */ +"not synchronized" = "Nicht synchronisiert"; + +/* No comment provided by engineer. */ +"Notes" = "Anmerkungen"; + /* No comment provided by engineer. */ "Nothing selected" = "Nichts ausgewählt"; @@ -3342,9 +3639,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!"; +/* alert title */ +"Notifications error" = "Benachrichtigungs-Fehler"; + /* No comment provided by engineer. */ "Notifications privacy" = "Datenschutz für Benachrichtigungen"; +/* alert title */ +"Notifications status" = "Benachrichtigungs-Status"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Administratoren können nun\n- Nachrichten von Gruppenmitgliedern löschen\n- Gruppenmitglieder deaktivieren (\"Beobachter\"-Rolle)"; @@ -3352,8 +3655,9 @@ "observer" = "Beobachter"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "Aus"; /* blur media */ @@ -3365,7 +3669,8 @@ /* feature offered item */ "offered %@: %@" = "angeboten %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3407,6 +3712,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Nur Absender und Moderatoren sehen es"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Nur Sie und Moderatoren sehen es"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Nur Sie können Reaktionen auf Nachrichten geben."; @@ -3437,7 +3748,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Nur Ihr Kontakt kann Sprachnachrichten versenden."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Öffnen"; /* No comment provided by engineer. */ @@ -3455,6 +3766,9 @@ /* No comment provided by engineer. */ "Open group" = "Gruppe öffnen"; +/* alert title */ +"Open link?" = "Link öffnen?"; + /* authentication reason */ "Open migration to another device" = "Migration auf ein anderes Gerät öffnen"; @@ -3488,6 +3802,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "Oder zum privaten Teilen"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Chats in Listen verwalten"; + /* No comment provided by engineer. */ "other" = "Andere"; @@ -3545,9 +3862,18 @@ /* No comment provided by engineer. */ "peer-to-peer" = "Peer-to-Peer"; +/* No comment provided by engineer. */ +"pending" = "ausstehend"; + /* No comment provided by engineer. */ "Pending" = "Ausstehend"; +/* No comment provided by engineer. */ +"pending approval" = "ausstehende Genehmigung"; + +/* No comment provided by engineer. */ +"pending review" = "Ausstehende Überprüfung"; + /* No comment provided by engineer. */ "Periodic" = "Periodisch"; @@ -3614,6 +3940,18 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren."; +/* token info */ +"Please try to disable and re-enable notfications." = "Bitte versuchen Sie, die Benachrichtigungen zu deaktivieren und wieder zu aktivieren."; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können."; + +/* token info */ +"Please wait for token activation to complete." = "Bitte warten Sie, bis die Token-Aktivierung abgeschlossen ist."; + +/* token info */ +"Please wait for token to be registered." = "Bitte warten Sie auf die Registrierung des Tokens."; + /* No comment provided by engineer. */ "Polish interface" = "Polnische Bedienoberfläche"; @@ -3644,12 +3982,21 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Schutz der Privatsphäre Ihrer Kunden."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Datenschutz- und Nutzungsbedingungen."; + /* No comment provided by engineer. */ "Privacy redefined" = "Datenschutz neu definiert"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich."; + /* No comment provided by engineer. */ "Private filenames" = "Neutrale Dateinamen"; +/* No comment provided by engineer. */ +"Private media file names." = "Medien mit anonymisierten Dateinamen."; + /* No comment provided by engineer. */ "Private message routing" = "Privates Nachrichten-Routing"; @@ -3695,6 +4042,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Reaktionen auf Nachrichten nicht erlauben."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Melden von Nachrichten an Moderatoren nicht erlauben."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Das Senden von Direktnachrichten an Gruppenmitglieder nicht erlauben."; @@ -3813,7 +4163,7 @@ "Receiving address will be changed to a different server. Address change will complete after sender comes online." = "Die Empfängeradresse wird auf einen anderen Server geändert. Der Adresswechsel wird abgeschlossen, wenn der Absender wieder online ist."; /* No comment provided by engineer. */ -"Receiving file will be stopped." = "Der Empfang der Datei wird beendet."; +"Receiving file will be stopped." = "Das Herunterladen der Datei wird beendet."; /* No comment provided by engineer. */ "Receiving via" = "Empfangen über"; @@ -3857,8 +4207,17 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Reduzierter Batterieverbrauch"; +/* No comment provided by engineer. */ +"Register" = "Registrieren"; + +/* token info */ +"Register notification token?" = "Benachrichtigungs-Token registrieren?"; + +/* token status text */ +"Registered" = "Registriert"; + /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Ablehnen"; /* No comment provided by engineer. */ @@ -3867,6 +4226,12 @@ /* No comment provided by engineer. */ "Reject contact request" = "Kontaktanfrage ablehnen"; +/* alert title */ +"Reject member?" = "Mitglied ablehnen?"; + +/* No comment provided by engineer. */ +"rejected" = "abgelehnt"; + /* call status */ "rejected call" = "Abgelehnter Anruf"; @@ -3903,6 +4268,9 @@ /* profile update event chat item */ "removed contact address" = "Die Kontaktadresse wurde entfernt"; +/* No comment provided by engineer. */ +"removed from group" = "Von der Gruppe entfernt"; + /* profile update event chat item */ "removed profile picture" = "Das Profil-Bild wurde entfernt"; @@ -3936,6 +4304,42 @@ /* chat item action */ "Reply" = "Antwort"; +/* chat item action */ +"Report" = "Melden"; + +/* report reason */ +"Report content: only group moderators will see it." = "Inhalt melden: Nur Gruppenmoderatoren werden es sehen."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen."; + +/* report reason */ +"Report other: only group moderators will see it." = "Anderes melden: Nur Gruppenmoderatoren werden es sehen."; + +/* No comment provided by engineer. */ +"Report reason?" = "Grund der Meldung?"; + +/* alert title */ +"Report sent to moderators" = "Meldung wurde an die Moderatoren gesendet"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Spam melden: Nur Gruppenmoderatoren werden es sehen."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Verstoß melden: Nur Gruppenmoderatoren werden es sehen."; + +/* report in notification */ +"Report: %@" = "Meldung: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Melden von Nachrichten an Moderatoren ist nicht erlaubt."; + +/* No comment provided by engineer. */ +"Reports" = "Meldungen"; + +/* No comment provided by engineer. */ +"request to join rejected" = "Beitrittsanfrage abgelehnt"; + /* chat list item title */ "requested to connect" = "Zur Verbindung aufgefordert"; @@ -3991,10 +4395,19 @@ "Reveal" = "Aufdecken"; /* No comment provided by engineer. */ -"Review conditions" = "Nutzungsbedingungen einsehen"; +"review" = "Überprüfung"; /* No comment provided by engineer. */ -"Review later" = "Später einsehen"; +"Review conditions" = "Nutzungsbedingungen einsehen"; + +/* admission stage */ +"Review members" = "Überprüfung der Mitglieder"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Überprüfung der Mitglieder vor der Aufnahme (\"Anklopfen\")."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "Von Administratoren überprüft"; /* No comment provided by engineer. */ "Revoke" = "Widerrufen"; @@ -4012,18 +4425,21 @@ "Run chat" = "Chat starten"; /* No comment provided by engineer. */ -"Safely receive files" = "Dateien sicher empfangen"; +"Safely receive files" = "Dateien sicher herunterladen"; /* No comment provided by engineer. */ "Safer groups" = "Sicherere Gruppen"; /* alert button - chat item action */ +chat item action */ "Save" = "Speichern"; /* alert button */ "Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)"; +/* alert title */ +"Save admission settings?" = "Speichern der Aufnahme-Einstellungen?"; + /* alert button */ "Save and notify contact" = "Speichern und Kontakt benachrichtigen"; @@ -4039,6 +4455,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Gruppenprofil speichern"; +/* No comment provided by engineer. */ +"Save list" = "Liste speichern"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Passwort speichern und Chat öffnen"; @@ -4207,6 +4626,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Benachrichtigungen senden"; +/* No comment provided by engineer. */ +"Send private reports" = "Private Meldungen senden"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Senden Sie Fragen und Ideen"; @@ -4339,6 +4761,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Einen Tag festlegen"; +/* No comment provided by engineer. */ +"Set chat name…" = "Chat-Name festlegen…"; + /* No comment provided by engineer. */ "Set contact name…" = "Kontaktname festlegen…"; @@ -4351,6 +4776,12 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Anstelle der System-Authentifizierung festlegen."; +/* No comment provided by engineer. */ +"Set member admission" = "Aufnahme von Mitgliedern festlegen"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Verfallsdatum von Nachrichten in Chats festlegen."; + /* profile update event chat item */ "set new contact address" = "Es wurde eine neue Kontaktadresse festgelegt"; @@ -4382,7 +4813,7 @@ "Shape profile images" = "Form der Profil-Bilder"; /* alert action - chat item action */ +chat item action */ "Share" = "Teilen"; /* No comment provided by engineer. */ @@ -4421,6 +4852,9 @@ /* No comment provided by engineer. */ "Share with contacts" = "Mit Kontakten teilen"; +/* No comment provided by engineer. */ +"Short link" = "Verkürzter Link"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Bei Nachrichten, die über privates Routing versendet wurden, → anzeigen."; @@ -4463,6 +4897,9 @@ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "SimpleX-Adresse oder Einmal-Link?"; +/* simplex link type */ +"SimpleX channel link" = "SimpleX-Kanal-Link"; + /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX-Chat und Flux haben vereinbart, die von Flux betriebenen Server in die App aufzunehmen."; @@ -4547,6 +4984,10 @@ /* notification title */ "Somebody" = "Jemand"; +/* blocking reason +report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Quadratisch, kreisförmig oder irgendetwas dazwischen."; @@ -4584,13 +5025,13 @@ "Stop chat?" = "Chat beenden?"; /* cancel file action */ -"Stop file" = "Datei beenden"; +"Stop file" = "Herunterladen beenden"; /* No comment provided by engineer. */ -"Stop receiving file?" = "Den Empfang der Datei beenden?"; +"Stop receiving file?" = "Das Herunterladen der Datei beenden?"; /* No comment provided by engineer. */ -"Stop sending file?" = "Das Senden der Datei beenden?"; +"Stop sending file?" = "Das Hochladen der Datei beenden?"; /* alert action */ "Stop sharing" = "Teilen beenden"; @@ -4604,6 +5045,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Chat wird beendet"; +/* No comment provided by engineer. */ +"Storage" = "Ablage"; + /* No comment provided by engineer. */ "strike" = "durchstreichen"; @@ -4673,6 +5117,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Timeout der TCP-Verbindung"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-Port für Nachrichtenübermittlung"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4682,12 +5129,15 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Temporärer Datei-Fehler"; /* server test failure */ "Test failed at step %@." = "Der Test ist beim Schritt %@ fehlgeschlagen."; +/* No comment provided by engineer. */ +"Test notifications" = "Benachrichtigungen testen"; + /* No comment provided by engineer. */ "Test server" = "Teste Server"; @@ -4760,15 +5210,9 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Die alte Datenbank wurde während der Migration nicht entfernt. Sie kann gelöscht werden."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Das Profil wird nur mit Ihren Kontakten geteilt."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den/die Betreiber: **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Der zweite voreingestellte Netzwerk-Betreiber in der App!"; @@ -4803,13 +5247,16 @@ "They can be overridden in contact and group settings." = "Sie können in den Kontakteinstellungen überschrieben werden."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Es werden alle herunter- und hochgeladenen Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden!"; + +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden!"; /* E2EE info chat item */ "This chat is protected by end-to-end encryption." = "Dieser Chat ist durch Ende-zu-Ende-Verschlüsselung geschützt."; @@ -4838,9 +5285,15 @@ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Das ist Ihre eigene SimpleX-Adresse!"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Dieser Link wurde schon mit einem anderen Mobiltelefon genutzt. Bitte erstellen sie einen neuen Link in der Desktop-App."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Diese Nachricht wurde gelöscht oder bisher noch nicht empfangen."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**."; @@ -4907,6 +5360,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Inkognito beim Verbinden einschalten."; +/* token status */ +"Token status: %@." = "Token-Status: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Deckkraft der Symbolleiste"; @@ -5015,10 +5471,7 @@ /* authentication reason */ "Unlock app" = "App entsperren"; -/* No comment provided by engineer. */ -"unmute" = "Stummschaltung aufheben"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Stummschaltung aufheben"; /* No comment provided by engineer. */ @@ -5027,6 +5480,9 @@ /* swipe action */ "Unread" = "Ungelesen"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Verbindungs-Link wird nicht unterstützt"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Bis zu 100 der letzten Nachrichten werden an neue Mitglieder gesendet."; @@ -5042,6 +5498,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Einstellungen aktualisieren?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Aktualisierte Nutzungsbedingungen"; + /* rcv group event chat item */ "updated group profile" = "Aktualisiertes Gruppenprofil"; @@ -5117,18 +5576,30 @@ /* No comment provided by engineer. */ "Use servers" = "Verwende Server"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Kurze Links verwenden (BETA)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Verwenden Sie SimpleX-Chat-Server?"; /* No comment provided by engineer. */ "Use SOCKS proxy" = "SOCKS-Proxy nutzen"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Solange kein Port konfiguriert ist, wird TCP-Port %@ genutzt."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "TCP-Port 443 nur für voreingestellte Server verwenden."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Die App kann während eines Anrufs genutzt werden."; /* No comment provided by engineer. */ "Use the app with one hand." = "Die App mit einer Hand bedienen."; +/* No comment provided by engineer. */ +"Use web port" = "Web-Port nutzen"; + /* No comment provided by engineer. */ "User selection" = "Benutzer-Auswahl"; @@ -5193,10 +5664,10 @@ "video call (not e2e encrypted)" = "Videoanruf (nicht E2E verschlüsselt)"; /* No comment provided by engineer. */ -"Video will be received when your contact completes uploading it." = "Das Video wird empfangen, sobald Ihr Kontakt das Hochladen beendet hat."; +"Video will be received when your contact completes uploading it." = "Das Video wird heruntergeladen, sobald Ihr Kontakt das Hochladen beendet hat."; /* No comment provided by engineer. */ -"Video will be received when your contact is online, please wait or check later!" = "Das Video wird empfangen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!"; +"Video will be received when your contact is online, please wait or check later!" = "Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!"; /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videos und Dateien bis zu 1GB"; @@ -5348,6 +5819,9 @@ /* No comment provided by engineer. */ "You accepted connection" = "Sie haben die Verbindung akzeptiert"; +/* snd group event chat item */ +"you accepted this member" = "Sie haben dieses Mitglied übernommen"; + /* No comment provided by engineer. */ "You allow" = "Sie erlauben"; @@ -5405,9 +5879,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Sie können die Betreiber in den Netzwerk- und Servereinstellungen konfigurieren."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Sie können die Server über die Einstellungen konfigurieren."; @@ -5462,6 +5933,9 @@ /* alert message */ "You can view invitation link again in connection details." = "Den Einladungslink können Sie in den Details der Verbindung nochmals sehen."; +/* alert message */ +"You can view your reports in Chat with admins." = "Sie können Ihre Meldungen im Chat mit den Administratoren sehen."; + /* No comment provided by engineer. */ "You can't send messages!" = "Sie können keine Nachrichten versenden!"; @@ -5534,6 +6008,9 @@ /* chat list item description */ "you shared one-time link incognito" = "Sie haben Inkognito einen Einmal-Link geteilt"; +/* token info */ +"You should receive notifications." = "Sie sollten Benachrichtigungen erhalten."; + /* snd group event chat item */ "you unblocked %@" = "Sie haben %@ freigegeben"; @@ -5559,7 +6036,7 @@ "You will still receive calls and notifications from muted profiles when they are active." = "Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten."; +"You will stop receiving messages from this chat. Chat history will be preserved." = "Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten."; /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Sie werden von dieser Gruppe keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten."; @@ -5619,7 +6096,7 @@ "Your preferences" = "Ihre Präferenzen"; /* No comment provided by engineer. */ -"Your privacy" = "Ihre Privatsphäre"; +"Your privacy" = "Privatsphäre"; /* No comment provided by engineer. */ "Your profile" = "Mein Profil"; @@ -5627,15 +6104,15 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Ihr Profil **%@** wird geteilt."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt. SimpleX-Server können Ihr Profil nicht einsehen."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ihr Profil wurde geändert. Wenn Sie es speichern, wird das aktualisierte Profil an alle Ihre Kontakte gesendet."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Ihr Profil, Ihre Kontakte und zugestellten Nachrichten werden auf Ihrem Gerät gespeichert."; - /* No comment provided by engineer. */ "Your random profile" = "Ihr Zufallsprofil"; @@ -5651,6 +6128,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Ihre SimpleX-Adresse"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Ihre SMP-Server"; - diff --git a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings index 0dee85ad95..e0554c9fb6 100644 --- a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings @@ -14,5 +14,5 @@ "NSMicrophoneUsageDescription" = "SimpleX benötigt Zugriff auf das Mikrofon, um Audio- und Videoanrufe und die Aufnahme von Sprachnachrichten zu ermöglichen."; /* Privacy - Photo Library Additions Usage Description */ -"NSPhotoLibraryAddUsageDescription" = "SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder empfangene Bilder zu speichern"; +"NSPhotoLibraryAddUsageDescription" = "SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder heruntergeladene Bilder zu speichern"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index e7570f177e..a8782c401f 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (puede copiarse)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- mensajes de voz de hasta 5 minutos.\n- tiempo personalizado para mensajes temporales.\n- historial de edición."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 coloreado!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nuevo)"; /* No comment provided by engineer. */ "(this device v%@)" = "(este dispositivo v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuye](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -77,7 +56,7 @@ "**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Recuarda**: usar la misma base de datos en dos dispositivos hará que falle el descifrado de mensajes como protección de seguridad."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Atención**: NO podrás recuperar o cambiar la contraseña si la pierdes."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Atención**: Si la pierdes NO podrás recuperar o cambiar la contraseña."; /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Recomendado**: el token del dispositivo y las notificaciones se envían al servidor de notificaciones de SimpleX Chat, pero no el contenido del mensaje, su tamaño o su procedencia."; @@ -199,6 +178,9 @@ /* time interval */ "%d sec" = "%d segundo(s)"; +/* delete after time */ +"%d seconds(s)" = "%d segundos"; + /* integrity error chat item */ "%d skipped message(s)" = "%d mensaje(s) omitido(s)"; @@ -241,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld idiomas de interfaz nuevos"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld segundo(s)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld segundos"; @@ -289,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "un dia"; /* time interval */ @@ -298,12 +278,17 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuto"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "un mes"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "una semana"; +/* delete after time */ +"1 year" = "1 año"; + /* No comment provided by engineer. */ "1-time link" = "Enlace de un uso"; @@ -332,7 +317,7 @@ "A separate TCP connection will be used **for each chat profile you have in the app**." = "Se usará una conexión TCP independiente **por cada perfil que tengas en la aplicación**."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**.\n**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**.\n**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar."; /* No comment provided by engineer. */ "Abort" = "Cancelar"; @@ -356,8 +341,8 @@ "Accent" = "Color"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Aceptar"; /* No comment provided by engineer. */ @@ -370,7 +355,7 @@ "Accept contact request from %@?" = "¿Aceptar solicitud de contacto de %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Aceptar incógnito"; /* call status */ @@ -388,6 +373,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Errores de confirmación"; +/* token status text */ +"Active" = "Activo"; + /* No comment provided by engineer. */ "Active connections" = "Conexiones activas"; @@ -397,6 +385,9 @@ /* No comment provided by engineer. */ "Add friends" = "Añadir amigos"; +/* No comment provided by engineer. */ +"Add list" = "Añadir lista"; + /* No comment provided by engineer. */ "Add profile" = "Añadir perfil"; @@ -412,11 +403,14 @@ /* No comment provided by engineer. */ "Add to another device" = "Añadir a otro dispositivo"; +/* No comment provided by engineer. */ +"Add to list" = "Añadir a la lista"; + /* No comment provided by engineer. */ "Add welcome message" = "Añadir mensaje de bienvenida"; /* No comment provided by engineer. */ -"Add your team members to the conversations." = "Añade a los miembros de tu equipo a las conversaciones."; +"Add your team members to the conversations." = "Añade a miembros de tu equipo a las conversaciones."; /* No comment provided by engineer. */ "Added media & file servers" = "Servidores de archivos y multimedia añadidos"; @@ -443,7 +437,7 @@ "Address or 1-time link?" = "¿Dirección o enlace de un uso?"; /* No comment provided by engineer. */ -"Address settings" = "Configuración de dirección"; +"Address settings" = "Configurar dirección"; /* member role */ "admin" = "administrador"; @@ -469,11 +463,17 @@ /* chat item text */ "agreeing encryption…" = "acordando cifrado…"; +/* No comment provided by engineer. */ +"All" = "Todo"; + /* No comment provided by engineer. */ "All app data is deleted." = "Todos los datos de la aplicación se eliminarán."; /* No comment provided by engineer. */ -"All chats and messages will be deleted - this cannot be undone!" = "Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse!"; +"All chats and messages will be deleted - this cannot be undone!" = "Se eliminarán todos los chats y mensajes. ¡No puede deshacerse!"; + +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Todos los chats se quitarán de la lista %@ y esta será eliminada."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Al introducirlo todos los datos son eliminados."; @@ -491,10 +491,10 @@ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos."; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone!" = "Todos los mensajes serán borrados. ¡No podrá deshacerse!"; +"All messages will be deleted - this cannot be undone!" = "Todos los mensajes serán eliminados. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse!"; +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Se eliminarán todos los mensajes SOLO para tí. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "All new messages from %@ will be hidden!" = "¡Los mensajes nuevos de %@ estarán ocultos!"; @@ -502,6 +502,12 @@ /* profile dropdown */ "All profiles" = "Todos los perfiles"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Todos los informes serán archivados para ti."; + +/* No comment provided by engineer. */ +"All servers" = "Todos los servidores"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Todos tus contactos permanecerán conectados."; @@ -547,6 +553,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Se permite la eliminación irreversible de mensajes. (24 horas)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Permitir informar de mensajes a los moderadores."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Se permite enviar archivos y multimedia."; @@ -601,6 +610,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "y %lld evento(s) más"; +/* report reason */ +"Another reason" = "Otro motivo"; + /* No comment provided by engineer. */ "Answer call" = "Responder llamada"; @@ -617,7 +629,10 @@ "App encrypts new local files (except videos)." = "Cifrado de los nuevos archivos locales (excepto vídeos)."; /* No comment provided by engineer. */ -"App icon" = "Icono aplicación"; +"App group:" = "Grupo app:"; + +/* No comment provided by engineer. */ +"App icon" = "Icono de la aplicación"; /* No comment provided by engineer. */ "App passcode" = "Código de acceso de la aplicación"; @@ -626,7 +641,7 @@ "App passcode is replaced with self-destruct passcode." = "El código de acceso será reemplazado por código de autodestrucción."; /* No comment provided by engineer. */ -"App session" = "Sesión de aplicación"; +"App session" = "por sesión"; /* No comment provided by engineer. */ "App version" = "Versión de la aplicación"; @@ -643,15 +658,36 @@ /* No comment provided by engineer. */ "Apply to" = "Aplicar a"; +/* No comment provided by engineer. */ +"Archive" = "Archivar"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "¿Archivar %lld informes?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "¿Archivar todos los informes?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivar y subir"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiva contactos para charlar más tarde."; +/* No comment provided by engineer. */ +"Archive report" = "Archivar informe"; + +/* No comment provided by engineer. */ +"Archive report?" = "¿Archivar informe?"; + +/* swipe action */ +"Archive reports" = "Archivar informes"; + /* No comment provided by engineer. */ "Archived contacts" = "Contactos archivados"; +/* No comment provided by engineer. */ +"archived report" = "informes archivados"; + /* No comment provided by engineer. */ "Archiving database" = "Archivando base de datos"; @@ -730,6 +766,9 @@ /* No comment provided by engineer. */ "Better groups" = "Grupos mejorados"; +/* No comment provided by engineer. */ +"Better groups performance" = "Rendimiento de grupos mejorado"; + /* No comment provided by engineer. */ "Better message dates." = "Sistema de fechas mejorado."; @@ -742,6 +781,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Notificaciones mejoradas"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Privacidad y seguridad mejoradas"; + /* No comment provided by engineer. */ "Better security ✅" = "Seguridad mejorada ✅"; @@ -775,7 +817,8 @@ /* rcv group event chat item */ "blocked %@" = "ha bloqueado a %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "bloqueado por administrador"; /* No comment provided by engineer. */ @@ -814,9 +857,15 @@ /* No comment provided by engineer. */ "Business chats" = "Chats empresariales"; +/* No comment provided by engineer. */ +"Businesses" = "Empresas"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Al usar SimpleX Chat, aceptas:\n- enviar únicamente contenido legal en los grupos públicos.\n- respetar a los demás usuarios – spam prohibido."; + /* No comment provided by engineer. */ "call" = "llamada"; @@ -857,7 +906,7 @@ "Can't message member" = "No se pueden enviar mensajes al miembro"; /* alert action - alert button */ +alert button */ "Cancel" = "Cancelar"; /* No comment provided by engineer. */ @@ -884,6 +933,9 @@ /* No comment provided by engineer. */ "Change" = "Cambiar"; +/* alert title */ +"Change automatic message deletion?" = "¿Modificar la eliminación automática de mensajes?"; + /* authentication reason */ "Change chat profiles" = "Cambiar perfil de usuario"; @@ -912,7 +964,7 @@ "Change self-destruct mode" = "Cambiar el modo de autodestrucción"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Cambiar código autodestrucción"; /* chat item text */ @@ -985,10 +1037,10 @@ "Chat theme" = "Tema de chat"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "El chat será eliminado para todos los miembros. ¡No podrá deshacerse!"; +"Chat will be deleted for all members - this cannot be undone!" = "El chat será eliminado para todos los miembros. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "El chat será eliminado para tí. ¡No podrá deshacerse!"; +"Chat will be deleted for you - this cannot be undone!" = "El chat será eliminado para tí. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Chats" = "Chats"; @@ -1033,7 +1085,13 @@ "Clear conversation?" = "¿Vaciar conversación?"; /* No comment provided by engineer. */ -"Clear private notes?" = "¿Borrar notas privadas?"; +"Clear group?" = "¿Vaciar grupo?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "¿Vaciar o eliminar grupo?"; + +/* No comment provided by engineer. */ +"Clear private notes?" = "¿Eliminar notas privadas?"; /* No comment provided by engineer. */ "Clear verification" = "Eliminar verificación"; @@ -1047,6 +1105,9 @@ /* No comment provided by engineer. */ "colored" = "coloreado"; +/* report reason */ +"Community guidelines violation" = "Violación de las normas de la comunidad"; + /* server test step */ "Compare file" = "Comparar archivo"; @@ -1071,12 +1132,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Condiciones de uso"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Las condiciones de los operadores habilitados serán aceptadas después de 30 días."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Las condiciones serán aceptadas para el/los operador(es): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Las condiciones serán aceptadas para el/los operador(es): **%@**."; @@ -1089,6 +1144,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "Configure servidores ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Configurar operadores de servidores"; + /* No comment provided by engineer. */ "Confirm" = "Confirmar"; @@ -1119,6 +1177,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Confirmar subida"; +/* token status text */ +"Confirmed" = "Confirmado"; + /* server test step */ "Connect" = "Conectar"; @@ -1177,7 +1238,7 @@ "Connected to desktop" = "Conectado con ordenador"; /* No comment provided by engineer. */ -"connecting" = "conectando"; +"connecting" = "conectando..."; /* No comment provided by engineer. */ "Connecting" = "Conectando"; @@ -1218,6 +1279,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Estado de tu conexión y servidores."; +/* No comment provided by engineer. */ +"Connection blocked" = "Conexión bloqueada"; + /* No comment provided by engineer. */ "Connection error" = "Error conexión"; @@ -1227,12 +1291,21 @@ /* chat list item title (it should not be shown */ "connection established" = "conexión establecida"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Conexión bloqueada por el operador del servidor:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Conexión no establecida."; + /* No comment provided by engineer. */ "Connection notifications" = "Notificaciones de conexión"; /* No comment provided by engineer. */ "Connection request sent!" = "¡Solicitud de conexión enviada!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "La conexión requiere renegociar el cifrado."; + /* No comment provided by engineer. */ "Connection security" = "Seguridad de conexión"; @@ -1285,7 +1358,7 @@ "Contact preferences" = "Preferencias de contacto"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "El contacto será eliminado. ¡No podrá deshacerse!"; +"Contact will be deleted - this cannot be undone!" = "El contacto será eliminado. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Contacts" = "Contactos"; @@ -1293,6 +1366,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos."; +/* blocking reason */ +"Content violates conditions of use" = "El contenido viola las condiciones de uso"; + /* No comment provided by engineer. */ "Continue" = "Continuar"; @@ -1335,6 +1411,9 @@ /* No comment provided by engineer. */ "Create link" = "Crear enlace"; +/* No comment provided by engineer. */ +"Create list" = "Crear lista"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻"; @@ -1476,7 +1555,8 @@ /* No comment provided by engineer. */ "decryption errors" = "errores de descifrado"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "predeterminado (%@)"; /* No comment provided by engineer. */ @@ -1486,8 +1566,7 @@ "default (yes)" = "predeterminado (sí)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Eliminar"; /* No comment provided by engineer. */ @@ -1514,6 +1593,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Eliminar chat"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Elimina los mensajes del dispositivo."; + /* No comment provided by engineer. */ "Delete chat profile" = "Eliminar perfil"; @@ -1568,14 +1650,17 @@ /* No comment provided by engineer. */ "Delete link?" = "¿Eliminar enlace?"; +/* alert title */ +"Delete list?" = "¿Eliminar lista?"; + /* No comment provided by engineer. */ "Delete member message?" = "¿Eliminar el mensaje de miembro?"; /* No comment provided by engineer. */ "Delete message?" = "¿Eliminar mensaje?"; -/* No comment provided by engineer. */ -"Delete messages" = "Eliminar mensaje"; +/* alert button */ +"Delete messages" = "Activar"; /* No comment provided by engineer. */ "Delete messages after" = "Eliminar en"; @@ -1587,7 +1672,7 @@ "Delete old database?" = "¿Eliminar base de datos antigua?"; /* No comment provided by engineer. */ -"Delete or moderate up to 200 messages." = "Borra o modera hasta 200 mensajes a la vez."; +"Delete or moderate up to 200 messages." = "Elimina o modera hasta 200 mensajes a la vez."; /* No comment provided by engineer. */ "Delete pending connection?" = "¿Eliminar conexión pendiente?"; @@ -1598,6 +1683,9 @@ /* server test step */ "Delete queue" = "Eliminar cola"; +/* No comment provided by engineer. */ +"Delete report" = "Eliminar informe"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Elimina hasta 20 mensajes a la vez."; @@ -1623,7 +1711,7 @@ "deleted contact" = "contacto eliminado"; /* rcv group event chat item */ -"deleted group" = "grupo eliminado"; +"deleted group" = "ha eliminado el grupo"; /* No comment provided by engineer. */ "Deletion errors" = "Errores de eliminación"; @@ -1706,6 +1794,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Desactivar (conservando anulaciones)"; +/* alert title */ +"Disable automatic message deletion?" = "¿Desactivar la eliminación automática de mensajes?"; + +/* alert button */ +"Disable delete messages" = "Desactivar"; + /* No comment provided by engineer. */ "Disable for all" = "Desactivar para todos"; @@ -1766,6 +1860,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NO uses SimpleX para llamadas de emergencia."; +/* No comment provided by engineer. */ +"Documents:" = "Documentos:"; + /* No comment provided by engineer. */ "Don't create address" = "No crear dirección SimpleX"; @@ -1773,13 +1870,19 @@ "Don't enable" = "No activar"; /* No comment provided by engineer. */ +"Don't miss important messages." = "No pierdas los mensajes importantes."; + +/* alert action */ "Don't show again" = "No volver a mostrar"; +/* No comment provided by engineer. */ +"Done" = "Hecho"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Degradar y abrir Chat"; /* alert button - chat item action */ +chat item action */ "Download" = "Descargar"; /* No comment provided by engineer. */ @@ -1836,14 +1939,14 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Activar (conservar anulaciones)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "¿Activar eliminación automática de mensajes?"; /* No comment provided by engineer. */ "Enable camera access" = "Permitir acceso a la cámara"; /* No comment provided by engineer. */ -"Enable Flux" = "Habilita Flux"; +"Enable Flux in Network & servers settings for better metadata privacy." = "Habilitar Flux en la configuración de Red y servidores para mejorar la privacidad de los metadatos."; /* No comment provided by engineer. */ "Enable for all" = "Activar para todos"; @@ -1897,7 +2000,7 @@ "Encrypt database?" = "¿Cifrar base de datos?"; /* No comment provided by engineer. */ -"Encrypt local files" = "Cifra archivos locales"; +"Encrypt local files" = "Cifrar archivos locales"; /* No comment provided by engineer. */ "Encrypt stored files & media" = "Cifra archivos almacenados y multimedia"; @@ -1956,6 +2059,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "se requiere renegociar el cifrado para %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Renegociación de cifrado en curso."; + /* No comment provided by engineer. */ "ended" = "finalizado"; @@ -1981,7 +2087,7 @@ "Enter password above to show!" = "¡Introduce la contraseña arriba para mostrar!"; /* No comment provided by engineer. */ -"Enter server manually" = "Introduce el servidor manualmente"; +"Enter server manually" = "Añadir manualmente"; /* No comment provided by engineer. */ "Enter this device name…" = "Nombre de este dispositivo…"; @@ -2031,6 +2137,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "¡Error al cambiar a incógnito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Error al verificar el estado del token"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde."; @@ -2043,6 +2152,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Error al crear enlace de grupo"; +/* alert title */ +"Error creating list" = "Error al crear lista"; + /* No comment provided by engineer. */ "Error creating member contact" = "Error al establecer contacto con el miembro"; @@ -2052,6 +2164,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "¡Error al crear perfil!"; +/* No comment provided by engineer. */ +"Error creating report" = "Error al crear informe"; + /* No comment provided by engineer. */ "Error decrypting file" = "Error al descifrar el archivo"; @@ -2118,12 +2233,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Error al reconectar con los servidores"; -/* No comment provided by engineer. */ -"Error removing member" = "Error al eliminar miembro"; +/* alert title */ +"Error registering for notifications" = "Error al registrarse para notificaciones"; + +/* alert title */ +"Error removing member" = "Error al expulsar miembro"; + +/* alert title */ +"Error reordering lists" = "Error al reorganizar listas"; /* No comment provided by engineer. */ "Error resetting statistics" = "Error al restablecer las estadísticas"; +/* alert title */ +"Error saving chat list" = "Error al guardar listas"; + /* No comment provided by engineer. */ "Error saving group profile" = "Error al guardar perfil de grupo"; @@ -2175,6 +2299,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Error al sincronizar conexión"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Error al testar la conexión al servidor"; + /* No comment provided by engineer. */ "Error updating group link" = "Error al actualizar enlace de grupo"; @@ -2199,7 +2326,9 @@ /* No comment provided by engineer. */ "Error: " = "Error: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Error: %@"; /* No comment provided by engineer. */ @@ -2217,9 +2346,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Incluso si está desactivado para la conversación."; -/* No comment provided by engineer. */ -"event happened" = "evento ocurrido"; - /* No comment provided by engineer. */ "Exit without saving" = "Salir sin guardar"; @@ -2229,6 +2355,9 @@ /* No comment provided by engineer. */ "expired" = "expirados"; +/* token status text */ +"Expired" = "Expirado"; + /* No comment provided by engineer. */ "Export database" = "Exportar base de datos"; @@ -2253,20 +2382,32 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "¡Rápido y sin necesidad de esperar a que el remitente esté en línea!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Eliminación más rápida de grupos."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Mensajería más segura y conexión más rápida."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Envío más rápido de mensajes."; + /* swipe action */ "Favorite" = "Favoritos"; /* No comment provided by engineer. */ +"Favorites" = "Favoritos"; + +/* file error alert title */ "File error" = "Error de archivo"; /* alert message */ "File errors:\n%@" = "Error(es) de archivo\n%@"; /* file error text */ -"File not found - most likely file was deleted or cancelled." = "Archivo no encontrado, probablemente haya sido borrado o cancelado."; +"File is blocked by server operator:\n%@." = "Archivo bloqueado por el operador del servidor\n%@."; + +/* file error text */ +"File not found - most likely file was deleted or cancelled." = "Archivo no encontrado, probablemente haya sido eliminado o cancelado."; /* file error text */ "File server error: %@" = "Error del servidor de archivos: %@"; @@ -2341,7 +2482,7 @@ "Fix not supported by group member" = "Corrección no compatible con miembro del grupo"; /* No comment provided by engineer. */ -"for better metadata privacy." = "para mejorar la privacidad de los metadatos."; +"For all moderators" = "Para todos los moderadores"; /* servers error */ "For chat profile %@:" = "Para el perfil de chat %@:"; @@ -2353,7 +2494,10 @@ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Si por ejemplo tu contacto recibe los mensajes a través de un servidor de SimpleX Chat, tu aplicación los entregará a través de un servidor de Flux."; /* No comment provided by engineer. */ -"For private routing" = "Para el enrutamiento privado"; +"For me" = "para mí"; + +/* No comment provided by engineer. */ +"For private routing" = "Para enrutamiento privado"; /* No comment provided by engineer. */ "For social media" = "Para redes sociales"; @@ -2424,6 +2568,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Reducción consumo de batería"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Las menciones ahora se notifican."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIFs y stickers"; @@ -2491,14 +2638,20 @@ "Group welcome message" = "Mensaje de bienvenida en grupos"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "El grupo será eliminado para todos los miembros. ¡No podrá deshacerse!"; +"Group will be deleted for all members - this cannot be undone!" = "El grupo será eliminado para todos los miembros. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"Group will be deleted for you - this cannot be undone!" = "El grupo será eliminado para tí. ¡No podrá deshacerse!"; +"Group will be deleted for you - this cannot be undone!" = "El grupo será eliminado para tí. ¡No puede deshacerse!"; + +/* No comment provided by engineer. */ +"Groups" = "Grupos"; /* No comment provided by engineer. */ "Help" = "Ayuda"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Ayuda a los admins a moderar sus grupos."; + /* No comment provided by engineer. */ "Hidden" = "Oculto"; @@ -2535,6 +2688,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Cómo ayuda a la privacidad"; +/* alert button */ +"How it works" = "Cómo funciona"; + /* No comment provided by engineer. */ "How SimpleX works" = "Cómo funciona SimpleX"; @@ -2622,6 +2778,12 @@ /* No comment provided by engineer. */ "inactive" = "inactivo"; +/* report reason */ +"Inappropriate content" = "Contenido inapropiado"; + +/* report reason */ +"Inappropriate profile" = "Perfil inapropiado"; + /* No comment provided by engineer. */ "Incognito" = "Incógnito"; @@ -2688,6 +2850,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Colores del interfaz"; +/* token status text */ +"Invalid" = "No válido"; + +/* token status text */ +"Invalid (bad token)" = "No válido (token incorrecto)"; + +/* token status text */ +"Invalid (expired)" = "No válido (expirado)"; + +/* token status text */ +"Invalid (unregistered)" = "No válido (no registrado)"; + +/* token status text */ +"Invalid (wrong topic)" = "No válido (tópico incorrecto)"; + /* invalid chat data */ "invalid chat" = "chat no válido"; @@ -2889,6 +3066,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Ordenadores enlazados"; +/* swipe action */ +"List" = "Lista"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "El nombre y el emoji deben ser diferentes en todas las listas."; + +/* No comment provided by engineer. */ +"List name..." = "Nombre de la lista..."; + /* No comment provided by engineer. */ "LIVE" = "EN VIVO"; @@ -2958,6 +3144,9 @@ /* item status text */ "Member inactive" = "Miembro inactivo"; +/* chat feature */ +"Member reports" = "Informes de miembros"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "El rol del miembro cambiará a \"%@\" y todos serán notificados."; @@ -2968,10 +3157,10 @@ "Member role will be changed to \"%@\". The member will receive a new invitation." = "El rol del miembro cambiará a \"%@\" y recibirá una invitación nueva."; /* No comment provided by engineer. */ -"Member will be removed from chat - this cannot be undone!" = "El miembro será eliminado del chat. ¡No podrá deshacerse!"; +"Member will be removed from chat - this cannot be undone!" = "El miembro será eliminado del chat. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"Member will be removed from group - this cannot be undone!" = "El miembro será expulsado del grupo. ¡No podrá deshacerse!"; +"Member will be removed from group - this cannot be undone!" = "El miembro será expulsado del grupo. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Members can add message reactions." = "Los miembros pueden añadir reacciones a los mensajes."; @@ -2979,6 +3168,9 @@ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Los miembros del grupo pueden eliminar mensajes de forma irreversible. (24 horas)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Los miembros pueden informar de mensajes a los moderadores."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Los miembros del grupo pueden enviar mensajes directos."; @@ -2994,6 +3186,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "Los miembros del grupo pueden enviar mensajes de voz."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Menciona a miembros 👋"; + /* No comment provided by engineer. */ "Menus" = "Menus"; @@ -3066,6 +3261,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "¡Los mensajes de %@ serán mostrados!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Los mensajes de esta conversación nunca se eliminan."; + /* No comment provided by engineer. */ "Messages received" = "Mensajes recibidos"; @@ -3073,7 +3271,7 @@ "Messages sent" = "Mensajes enviados"; /* alert message */ -"Messages were deleted after you selected them." = "Los mensajes han sido borrados después de seleccionarlos."; +"Messages were deleted after you selected them." = "Los mensajes han sido eliminados después de seleccionarlos."; /* No comment provided by engineer. */ "Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Los mensajes, archivos y llamadas están protegidos mediante **cifrado de extremo a extremo** con secreto perfecto hacía adelante, repudio y recuperación tras ataque."; @@ -3138,9 +3336,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "moderado por %@"; +/* member role */ +"moderator" = "moderador"; + /* time unit */ "months" = "meses"; +/* swipe action */ +"More" = "Más"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "¡Pronto habrá más mejoras!"; @@ -3156,12 +3360,12 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Múltiples perfiles"; -/* No comment provided by engineer. */ -"mute" = "silenciar"; - -/* swipe action */ +/* notification label action */ "Mute" = "Silenciar"; +/* notification label action */ +"Mute all" = "Silenciar todo"; + /* No comment provided by engineer. */ "Muted when inactive!" = "¡Silenciado cuando está inactivo!"; @@ -3192,9 +3396,12 @@ /* No comment provided by engineer. */ "Network status" = "Estado de la red"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nunca"; +/* token status text */ +"New" = "Nuevo"; + /* No comment provided by engineer. */ "New chat" = "Nuevo chat"; @@ -3244,7 +3451,7 @@ "New SOCKS credentials will be used every time you start the app." = "Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación."; /* No comment provided by engineer. */ -"New SOCKS credentials will be used for each server." = "Se usarán credenciales SOCKS nuevas por cada servidor."; +"New SOCKS credentials will be used for each server." = "Se usarán credenciales SOCKS nuevas para cada servidor."; /* pref value */ "no" = "no"; @@ -3255,6 +3462,15 @@ /* Authentication unavailable */ "No app password" = "Sin contraseña de la aplicación"; +/* No comment provided by engineer. */ +"No chats" = "Sin chats"; + +/* No comment provided by engineer. */ +"No chats found" = "Ningún chat encontrado"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Sin chats en la lista %@"; + /* No comment provided by engineer. */ "No contacts selected" = "Ningún contacto seleccionado"; @@ -3286,10 +3502,13 @@ "No info, try to reload" = "No hay información, intenta recargar"; /* servers error */ -"No media & file servers." = "Ningún servidor de archivos y multimedia."; +"No media & file servers." = "Sin servidores para archivos y multimedia."; + +/* No comment provided by engineer. */ +"No message" = "Ningún mensaje"; /* servers error */ -"No message servers." = "Ningún servidor de mensajes."; +"No message servers." = "Sin servidores para mensajes."; /* No comment provided by engineer. */ "No network connection" = "Sin conexión de red"; @@ -3304,32 +3523,41 @@ "No permission to record voice message" = "Sin permiso para grabar mensajes de voz"; /* No comment provided by engineer. */ -"No push server" = "Ningún servidor push"; +"No push server" = "Sin servidores push"; /* No comment provided by engineer. */ "No received or sent files" = "Sin archivos recibidos o enviados"; /* servers error */ -"No servers for private message routing." = "Ningún servidor para enrutamiento privado."; +"No servers for private message routing." = "Sin servidores para enrutamiento privado."; /* servers error */ -"No servers to receive files." = "Ningún servidor para recibir archivos."; +"No servers to receive files." = "Sin servidores para recibir archivos."; /* servers error */ -"No servers to receive messages." = "Ningún servidor para recibir mensajes."; +"No servers to receive messages." = "Sin servidores para recibir mensajes."; /* servers error */ -"No servers to send files." = "Ningún servidor para enviar archivos."; +"No servers to send files." = "Sin servidores para enviar archivos."; /* copied message info in history */ "no text" = "sin texto"; +/* alert title */ +"No token!" = "¡Sin token!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Ningún chat sin leer"; + /* No comment provided by engineer. */ "No user identifiers." = "Sin identificadores de usuario."; /* No comment provided by engineer. */ "Not compatible!" = "¡No compatible!"; +/* No comment provided by engineer. */ +"Notes" = "Notas"; + /* No comment provided by engineer. */ "Nothing selected" = "Nada seleccionado"; @@ -3342,9 +3570,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "¡Las notificaciones están desactivadas!"; +/* alert title */ +"Notifications error" = "Error en notificaciones"; + /* No comment provided by engineer. */ "Notifications privacy" = "Privacidad en las notificaciones"; +/* alert title */ +"Notifications status" = "Estado notificaciones"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Ahora los administradores pueden:\n- eliminar mensajes de los miembros.\n- desactivar el rol miembro (a rol \"observador\")"; @@ -3352,8 +3586,9 @@ "observer" = "observador"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "desactivado"; /* blur media */ @@ -3365,7 +3600,8 @@ /* feature offered item */ "offered %@: %@" = "ofrecido %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3396,7 +3632,7 @@ "Only client devices store user profiles, contacts, groups, and messages." = "Sólo los dispositivos cliente almacenan perfiles de usuario, contactos, grupos y mensajes enviados con **cifrado de extremo a extremo de 2 capas**."; /* No comment provided by engineer. */ -"Only delete conversation" = "Sólo borrar la conversación"; +"Only delete conversation" = "Eliminar sólo la conversación"; /* No comment provided by engineer. */ "Only group owners can change group preferences." = "Sólo los propietarios pueden modificar las preferencias del grupo."; @@ -3407,6 +3643,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Sólo los propietarios del grupo pueden activar los mensajes de voz."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Solo el remitente y el moderador pueden verlo"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Solo tú y los moderadores podéis verlo"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Sólo tú puedes añadir reacciones a los mensajes."; @@ -3437,7 +3679,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sólo tu contacto puede enviar mensajes de voz."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Abrir"; /* No comment provided by engineer. */ @@ -3483,11 +3725,14 @@ "Or securely share this file link" = "O comparte de forma segura este enlace al archivo"; /* No comment provided by engineer. */ -"Or show this code" = "O muestra este código QR"; +"Or show this code" = "O muestra el código QR"; /* No comment provided by engineer. */ "Or to share privately" = "O para compartir en privado"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organiza tus chats en listas"; + /* No comment provided by engineer. */ "other" = "otros"; @@ -3545,9 +3790,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "p2p"; +/* No comment provided by engineer. */ +"pending" = "pendiente"; + /* No comment provided by engineer. */ "Pending" = "Pendientes"; +/* No comment provided by engineer. */ +"pending approval" = "pendiente de aprobación"; + /* No comment provided by engineer. */ "Periodic" = "Periódicamente"; @@ -3614,6 +3865,15 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes."; +/* token info */ +"Please try to disable and re-enable notfications." = "Por favor, intenta desactivar y reactivar las notificaciones."; + +/* token info */ +"Please wait for token activation to complete." = "Por favor, espera a que el token de activación se complete."; + +/* token info */ +"Please wait for token to be registered." = "Por favor, espera a que el token se registre."; + /* No comment provided by engineer. */ "Polish interface" = "Interfaz en polaco"; @@ -3644,12 +3904,21 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Privacidad para tus clientes."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Política de privacidad y condiciones de uso."; + /* No comment provided by engineer. */ "Privacy redefined" = "Privacidad redefinida"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores."; + /* No comment provided by engineer. */ "Private filenames" = "Nombres de archivos privados"; +/* No comment provided by engineer. */ +"Private media file names." = "Nombres privados en archivos de media."; + /* No comment provided by engineer. */ "Private message routing" = "Enrutamiento privado de mensajes"; @@ -3695,6 +3964,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "No se permiten reacciones a los mensajes."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "No se permite informar de mensajes a los moderadores."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "No se permiten mensajes directos entre miembros."; @@ -3711,7 +3983,7 @@ "Prohibit sending voice messages." = "No se permiten mensajes de voz."; /* No comment provided by engineer. */ -"Protect app screen" = "Proteger la pantalla de la aplicación"; +"Protect app screen" = "Proteger la pantalla"; /* No comment provided by engineer. */ "Protect IP address" = "Proteger dirección IP"; @@ -3753,7 +4025,7 @@ "Rate the app" = "Valora la aplicación"; /* No comment provided by engineer. */ -"Reachable chat toolbar" = "Barra de herramientas accesible"; +"Reachable chat toolbar" = "Barra de menú accesible"; /* chat item menu */ "React…" = "Reacciona…"; @@ -3840,7 +4112,7 @@ "Reconnect all servers?" = "¿Reconectar todos los servidores?"; /* No comment provided by engineer. */ -"Reconnect server to force message delivery. It uses additional traffic." = "Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional."; +"Reconnect server to force message delivery. It uses additional traffic." = "Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional."; /* No comment provided by engineer. */ "Reconnect server?" = "¿Reconectar servidor?"; @@ -3857,8 +4129,17 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Reducción del uso de batería"; +/* No comment provided by engineer. */ +"Register" = "Registrar"; + +/* token info */ +"Register notification token?" = "¿Registrar el token de notificaciones?"; + +/* token status text */ +"Registered" = "Registrado"; + /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Rechazar"; /* No comment provided by engineer. */ @@ -3867,6 +4148,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Rechazar solicitud de contacto"; +/* No comment provided by engineer. */ +"rejected" = "rechazado"; + /* call status */ "rejected call" = "llamada rechazada"; @@ -3936,6 +4220,36 @@ /* chat item action */ "Reply" = "Responder"; +/* chat item action */ +"Report" = "Informe"; + +/* report reason */ +"Report content: only group moderators will see it." = "Informar de contenido: sólo los moderadores del grupo lo verán."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Informar del perfil de un miembro: sólo los moderadores del grupo lo verán."; + +/* report reason */ +"Report other: only group moderators will see it." = "Informar de otros: sólo los moderadores del grupo lo verán."; + +/* No comment provided by engineer. */ +"Report reason?" = "¿Motivo del informe?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Informar de spam: sólo los moderadores del grupo lo verán."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Informar de violación: sólo los moderadores del grupo lo verán."; + +/* report in notification */ +"Report: %@" = "Informe: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "No se permite informar de mensajes a los moderadores."; + +/* No comment provided by engineer. */ +"Reports" = "Informes"; + /* chat list item title */ "requested to connect" = "solicitado para conectar"; @@ -3993,9 +4307,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Revisar condiciones"; -/* No comment provided by engineer. */ -"Review later" = "Revisar más tarde"; - /* No comment provided by engineer. */ "Revoke" = "Revocar"; @@ -4018,7 +4329,7 @@ "Safer groups" = "Grupos más seguros"; /* alert button - chat item action */ +chat item action */ "Save" = "Guardar"; /* alert button */ @@ -4039,6 +4350,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Guardar perfil de grupo"; +/* No comment provided by engineer. */ +"Save list" = "Guardar lista"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Guardar contraseña y abrir el chat"; @@ -4103,7 +4417,7 @@ "Scan security code from your contact's app." = "Escanea el código de seguridad desde la aplicación de tu contacto."; /* No comment provided by engineer. */ -"Scan server QR code" = "Escanear código QR del servidor"; +"Scan server QR code" = "Escanear código QR"; /* No comment provided by engineer. */ "search" = "buscar"; @@ -4207,6 +4521,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Enviar notificaciones"; +/* No comment provided by engineer. */ +"Send private reports" = "Envía informes privados"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Consultas y sugerencias"; @@ -4331,7 +4648,7 @@ "Servers info" = "Info servidores"; /* No comment provided by engineer. */ -"Servers statistics will be reset - this cannot be undone!" = "Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse!"; +"Servers statistics will be reset - this cannot be undone!" = "Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Session code" = "Código de sesión"; @@ -4339,6 +4656,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Establecer 1 día"; +/* No comment provided by engineer. */ +"Set chat name…" = "Nombre para el chat…"; + /* No comment provided by engineer. */ "Set contact name…" = "Escribe el nombre del contacto…"; @@ -4351,6 +4671,9 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Úsalo en lugar de la autenticación del sistema."; +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Establece el vencimiento para los mensajes en los chats."; + /* profile update event chat item */ "set new contact address" = "nueva dirección de contacto"; @@ -4382,7 +4705,7 @@ "Shape profile images" = "Dar forma a las imágenes de perfil"; /* alert action - chat item action */ +chat item action */ "Share" = "Compartir"; /* No comment provided by engineer. */ @@ -4407,7 +4730,7 @@ "Share link" = "Compartir enlace"; /* No comment provided by engineer. */ -"Share profile" = "Comparte perfil"; +"Share profile" = "Perfil a compartir"; /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Comparte tu dirección SimpleX en redes sociales."; @@ -4421,6 +4744,9 @@ /* No comment provided by engineer. */ "Share with contacts" = "Compartir con contactos"; +/* No comment provided by engineer. */ +"Short link" = "Enlace corto"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Mostrar → en mensajes con enrutamiento privado."; @@ -4458,13 +4784,16 @@ "SimpleX Address" = "Dirección SimpleX"; /* No comment provided by engineer. */ -"SimpleX address and 1-time links are safe to share via any messenger." = "Compartir los enlaces de un uso y las direcciones SimpleX es seguro a través de cualquier medio."; +"SimpleX address and 1-time links are safe to share via any messenger." = "Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio."; /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "¿Dirección SimpleX o enlace de un uso?"; +/* simplex link type */ +"SimpleX channel link" = "Enlace de canal SimpleX"; + /* No comment provided by engineer. */ -"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación"; +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux."; /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La seguridad de SimpleX Chat ha sido auditada por Trail of Bits."; @@ -4518,7 +4847,7 @@ "Skipped messages" = "Mensajes omitidos"; /* No comment provided by engineer. */ -"Small groups (max 20)" = "Grupos pequeños (máx. 20)"; +"Small groups (max 20)" = "Grupos pequeños (max. 20)"; /* No comment provided by engineer. */ "SMP server" = "Servidor SMP"; @@ -4547,6 +4876,10 @@ /* notification title */ "Somebody" = "Alguien"; +/* blocking reason +report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Cuadrada, circular o cualquier forma intermedia."; @@ -4578,7 +4911,7 @@ "Stop chat" = "Parar SimpleX"; /* No comment provided by engineer. */ -"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para poder exportar, importar o eliminar la base de datos primero debes parar SimpleX. Mientras tanto no podrás recibir ni enviar mensajes."; +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes."; /* No comment provided by engineer. */ "Stop chat?" = "¿Parar Chat?"; @@ -4604,6 +4937,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Parando chat"; +/* No comment provided by engineer. */ +"Storage" = "Almacenamiento"; + /* No comment provided by engineer. */ "strike" = "tachado"; @@ -4614,7 +4950,7 @@ "Submit" = "Enviar"; /* No comment provided by engineer. */ -"Subscribed" = "Suscrito"; +"Subscribed" = "Suscritas"; /* No comment provided by engineer. */ "Subscription errors" = "Errores de suscripción"; @@ -4673,6 +5009,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Timeout de la conexión TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Puerto TCP para mensajes"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4682,12 +5021,15 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Error en archivo temporal"; /* server test failure */ "Test failed at step %@." = "Prueba no superada en el paso %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Probar notificaciones"; + /* No comment provided by engineer. */ "Test server" = "Probar servidor"; @@ -4719,7 +5061,7 @@ "The attempt to change database passphrase was not completed." = "El intento de cambiar la contraseña de la base de datos no se ha completado."; /* No comment provided by engineer. */ -"The code you scanned is not a SimpleX link QR code." = "El código QR escaneado no es un enlace SimpleX."; +"The code you scanned is not a SimpleX link QR code." = "El código QR escaneado no es un enlace de SimpleX."; /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado."; @@ -4760,15 +5102,9 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "La base de datos antigua no se eliminó durante la migración, puede eliminarse."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Las mismas condiciones se aplicarán al operador **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Las mismas condiciones se aplicarán a el/los operador(es) **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "¡Segundo operador predefinido!"; @@ -4779,13 +5115,13 @@ "The sender will NOT be notified" = "El remitente NO será notificado"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "Lista de servidores para las conexiones nuevas del perfil **%@**."; +"The servers for new connections of your current chat profile **%@**." = "Servidores para conexiones nuevas en tu perfil **%@**."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Los servidores para archivos nuevos en tu perfil actual **%@**."; +"The servers for new files of your current chat profile **%@**." = "Servidores para enviar archivos en tu perfil **%@**."; /* No comment provided by engineer. */ -"The text you pasted is not a SimpleX link." = "El texto pegado no es un enlace SimpleX."; +"The text you pasted is not a SimpleX link." = "El texto pegado no es un enlace de SimpleX."; /* No comment provided by engineer. */ "The uploaded database archive will be permanently removed from the servers." = "El archivo de bases de datos subido será eliminado permanentemente de los servidores."; @@ -4808,6 +5144,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Esta acción es irreversible. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Podría tardar varios minutos."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No puede deshacerse!"; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente."; @@ -4838,9 +5177,15 @@ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "¡Esta es tu propia dirección SimpleX!"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "El mensaje ha sido eliminado o aún no se ha recibido."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Esta configuración se aplica a los mensajes del perfil actual **%@**."; @@ -4872,7 +5217,7 @@ "To protect your IP address, private routing uses your SMP servers to deliver messages." = "Para proteger tu dirección IP, el enrutamiento privado usa tu lista de servidores SMP para enviar mensajes."; /* No comment provided by engineer. */ -"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Para proteger tu privacidad, en lugar de los identificadores de usuario que usan el resto de plataformas, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos."; +"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos."; /* No comment provided by engineer. */ "To receive" = "Para recibir"; @@ -4896,7 +5241,7 @@ "To support instant push notifications the chat database has to be migrated." = "Para permitir las notificaciones automáticas instantáneas, la base de datos se debe migrar."; /* No comment provided by engineer. */ -"To use the servers of **%@**, accept conditions of use." = "Para usar los servidores de **%@**, acepta las condiciones de uso."; +"To use the servers of **%@**, accept conditions of use." = "Para usar los servidores de **%@**, debes aceptar las condiciones de uso."; /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos."; @@ -4907,6 +5252,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Activa incógnito al conectar."; +/* token status */ +"Token status: %@." = "Estado token: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Opacidad barra"; @@ -4947,7 +5295,7 @@ "Unblock member" = "Desbloquear miembro"; /* No comment provided by engineer. */ -"Unblock member for all?" = "¿Desbloquear miembro para todos?"; +"Unblock member for all?" = "¿Desbloquear el miembro para todos?"; /* No comment provided by engineer. */ "Unblock member?" = "¿Desbloquear miembro?"; @@ -5001,7 +5349,7 @@ "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "A menos que utilices la interfaz de llamadas de iOS, activa el modo No molestar para evitar interrupciones."; /* No comment provided by engineer. */ -"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." = "A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo.\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red."; +"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." = "A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo.\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red."; /* No comment provided by engineer. */ "Unlink" = "Desenlazar"; @@ -5015,10 +5363,7 @@ /* authentication reason */ "Unlock app" = "Desbloquear aplicación"; -/* No comment provided by engineer. */ -"unmute" = "activar sonido"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Activar audio"; /* No comment provided by engineer. */ @@ -5027,6 +5372,9 @@ /* swipe action */ "Unread" = "No leído"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Enlace de conexión no compatible"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Hasta 100 últimos mensajes son enviados a los miembros nuevos."; @@ -5042,6 +5390,9 @@ /* No comment provided by engineer. */ "Update settings?" = "¿Actualizar configuración?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Condiciones actualizadas"; + /* rcv group event chat item */ "updated group profile" = "ha actualizado el perfil del grupo"; @@ -5049,7 +5400,7 @@ "updated profile" = "perfil actualizado"; /* No comment provided by engineer. */ -"Updating settings will re-connect the client to all servers." = "Al actualizar la configuración el cliente se reconectará a todos los servidores."; +"Updating settings will re-connect the client to all servers." = "Para actualizar la configuración el cliente se reconectará a todos los servidores."; /* No comment provided by engineer. */ "Upgrade and open chat" = "Actualizar y abrir Chat"; @@ -5085,13 +5436,13 @@ "Use current profile" = "Usar perfil actual"; /* No comment provided by engineer. */ -"Use for files" = "Usar para archivos"; +"Use for files" = "Uso para archivos"; /* No comment provided by engineer. */ -"Use for messages" = "Usar para mensajes"; +"Use for messages" = "Uso para mensajes"; /* No comment provided by engineer. */ -"Use for new connections" = "Usar para conexiones nuevas"; +"Use for new connections" = "Para conexiones nuevas"; /* No comment provided by engineer. */ "Use from desktop" = "Usar desde ordenador"; @@ -5109,7 +5460,7 @@ "Use private routing with unknown servers when IP address is not protected." = "Usar enrutamiento privado con servidores desconocidos cuando tu dirección IP no está protegida."; /* No comment provided by engineer. */ -"Use private routing with unknown servers." = "Usar enrutamiento privado con servidores de retransmisión desconocidos."; +"Use private routing with unknown servers." = "Usar enrutamiento privado con servidores de mensaje desconocidos."; /* No comment provided by engineer. */ "Use server" = "Usar servidor"; @@ -5117,18 +5468,30 @@ /* No comment provided by engineer. */ "Use servers" = "Usar servidores"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Usar enlaces cortos (BETA)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "¿Usar servidores SimpleX Chat?"; /* No comment provided by engineer. */ "Use SOCKS proxy" = "Usar proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Se usa el puerto TCP %@ cuando no se ha especificado otro."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Usar puerto TCP 443 solo en servidores predefinidos."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Usar la aplicación durante la llamada."; /* No comment provided by engineer. */ "Use the app with one hand." = "Usa la aplicación con una sola mano."; +/* No comment provided by engineer. */ +"Use web port" = "Usar puerto web"; + /* No comment provided by engineer. */ "User selection" = "Selección de usuarios"; @@ -5391,7 +5754,7 @@ "You are invited to group" = "Has sido invitado a un grupo"; /* No comment provided by engineer. */ -"You are not connected to these servers. Private routing is used to deliver messages to them." = "No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado."; +"You are not connected to these servers. Private routing is used to deliver messages to them." = "No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado."; /* No comment provided by engineer. */ "you are observer" = "Tu rol es observador"; @@ -5405,9 +5768,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Puedes cambiar la posición de la barra desde el menú Apariencia."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Puedes configurar los operadores desde Servidores y Redes."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Puedes configurar los servidores a través de su configuración."; @@ -5514,7 +5874,7 @@ "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Debes usar la versión más reciente de tu base de datos ÚNICAMENTE en un dispositivo, de lo contrario podrías dejar de recibir mensajes de algunos contactos."; /* No comment provided by engineer. */ -"You need to allow your contact to call to be able to call them." = "Necesitas permitir que tus contacto llamen para poder llamarles."; +"You need to allow your contact to call to be able to call them." = "Debes permitir que tus contacto te llamen para poder llamarles."; /* No comment provided by engineer. */ "You need to allow your contact to send voice messages to be able to send them." = "Para poder enviar mensajes de voz antes debes permitir que tu contacto pueda enviarlos."; @@ -5534,6 +5894,9 @@ /* chat list item description */ "you shared one-time link incognito" = "has compartido enlace de un solo uso en modo incógnito"; +/* token info */ +"You should receive notifications." = "Deberías recibir notificaciones."; + /* snd group event chat item */ "you unblocked %@" = "has desbloqueado a %@"; @@ -5627,15 +5990,15 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "El perfil **%@** será compartido."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil es almacenado en tu dispositivo y solamente se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Tu perfil, contactos y mensajes se almacenan en tu dispositivo."; - /* No comment provided by engineer. */ "Your random profile" = "Tu perfil aleatorio"; @@ -5651,6 +6014,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Mi dirección SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Servidores SMP"; - diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index f0987f3e1b..8e489f7a71 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (voidaan kopioida)"; @@ -16,24 +10,9 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- ääniviestit enintään 5 minuuttia.\n- mukautettu katoamisaika.\n- historian muokkaaminen."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 värillinen!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Osallistu](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -160,9 +139,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld uutta käyttöliittymän kieltä"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld sekunti(a)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld sekuntia"; @@ -205,7 +181,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 päivä"; /* time interval */ @@ -214,10 +191,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuutti"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 kuukausi"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 viikko"; /* No comment provided by engineer. */ @@ -260,8 +239,8 @@ "above, then choose:" = "edellä, valitse sitten:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Hyväksy"; /* No comment provided by engineer. */ @@ -271,7 +250,7 @@ "Accept contact request from %@?" = "Hyväksy kontaktipyyntö %@:ltä?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Hyväksy tuntematon"; /* call status */ @@ -530,7 +509,7 @@ "Can't invite contacts!" = "Kontakteja ei voi kutsua!"; /* alert action - alert button */ +alert button */ "Cancel" = "Peruuta"; /* feature offered item */ @@ -570,7 +549,7 @@ "Change self-destruct mode" = "Vaihda itsetuhotilaa"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Vaihda itsetuhoutuva pääsykoodi"; /* chat item text */ @@ -789,6 +768,9 @@ /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻"; +/* No comment provided by engineer. */ +"Create profile" = "Luo profiilisi"; + /* server test step */ "Create queue" = "Luo jono"; @@ -888,7 +870,8 @@ /* message decrypt error item */ "Decryption error" = "Salauksen purkuvirhe"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "oletusarvo (%@)"; /* No comment provided by engineer. */ @@ -898,8 +881,7 @@ "default (yes)" = "oletusarvo (kyllä)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Poista"; /* No comment provided by engineer. */ @@ -965,7 +947,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Poista viesti?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Poista viestit"; /* No comment provided by engineer. */ @@ -1091,7 +1073,7 @@ /* No comment provided by engineer. */ "Don't enable" = "Älä salli"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Älä näytä uudelleen"; /* No comment provided by engineer. */ @@ -1124,7 +1106,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Salli (pidä ohitukset)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Ota automaattinen viestien poisto käyttöön?"; /* No comment provided by engineer. */ @@ -1325,7 +1307,7 @@ /* alert title */ "Error receiving file" = "Virhe tiedoston vastaanottamisessa"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Virhe poistettaessa jäsentä"; /* No comment provided by engineer. */ @@ -1379,7 +1361,9 @@ /* No comment provided by engineer. */ "Error: " = "Virhe: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Virhe: %@"; /* No comment provided by engineer. */ @@ -1391,9 +1375,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Jopa kun ei käytössä keskustelussa."; -/* No comment provided by engineer. */ -"event happened" = "tapahtuma tapahtui"; - /* No comment provided by engineer. */ "Exit without saving" = "Poistu tallentamatta"; @@ -1988,7 +1969,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Useita keskusteluprofiileja"; -/* swipe action */ +/* notification label action */ "Mute" = "Mykistä"; /* No comment provided by engineer. */ @@ -2006,7 +1987,7 @@ /* No comment provided by engineer. */ "Network status" = "Verkon tila"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ei koskaan"; /* notification */ @@ -2097,8 +2078,9 @@ "observer" = "tarkkailija"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "pois"; /* blur media */ @@ -2110,7 +2092,8 @@ /* feature offered item */ "offered %@: %@" = "tarjottu %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -2402,7 +2385,7 @@ "Reduced battery usage" = "Pienempi akun käyttö"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Hylkää"; /* No comment provided by engineer. */ @@ -2502,7 +2485,7 @@ "Run chat" = "Käynnistä chat"; /* alert button - chat item action */ +chat item action */ "Save" = "Tallenna"; /* alert button */ @@ -2710,7 +2693,7 @@ "Settings" = "Asetukset"; /* alert action - chat item action */ +chat item action */ "Share" = "Jaa"; /* No comment provided by engineer. */ @@ -2929,9 +2912,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Profiili jaetaan vain kontaktiesi kanssa."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Toinen kuittaus, joka uupui! ✅"; @@ -3058,7 +3038,7 @@ /* authentication reason */ "Unlock app" = "Avaa sovellus"; -/* swipe action */ +/* notification label action */ "Unmute" = "Poista mykistys"; /* swipe action */ @@ -3410,10 +3390,10 @@ "Your profile **%@** will be shared." = "Profiilisi **%@** jaetaan."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. SimpleX-palvelimet eivät näe profiiliasi."; +"Your profile is stored on your device and only shared with your contacts." = "Profiili jaetaan vain kontaktiesi kanssa."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. SimpleX-palvelimet eivät näe profiiliasi."; /* No comment provided by engineer. */ "Your random profile" = "Satunnainen profiilisi"; @@ -3427,6 +3407,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "SimpleX-osoitteesi"; -/* No comment provided by engineer. */ -"Your SMP servers" = "SMP-palvelimesi"; - diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 6b973e75d0..9b570a5ae9 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (peut être copié)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messages vocaux pouvant durer jusqu'à 5 minutes.\n- délai personnalisé de disparition.\n- l'historique de modification."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 coloré!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nouveau)"; /* No comment provided by engineer. */ "(this device v%@)" = "(cet appareil v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuer](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -82,6 +61,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Recommandé** : le token de l'appareil et les notifications sont envoyés au serveur de notifications SimpleX, mais pas le contenu du message, sa taille ou son auteur."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**Scanner / Coller** : pour vous connecter via un lien que vous avez reçu."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Avertissement** : les notifications push instantanées nécessitent une phrase secrète enregistrée dans la keychain."; @@ -142,6 +124,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ est vérifié·e"; +/* No comment provided by engineer. */ +"%@ server" = "Serveur %@"; + +/* No comment provided by engineer. */ +"%@ servers" = "Serveurs %@"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ envoyé"; @@ -190,6 +178,9 @@ /* time interval */ "%d sec" = "%d sec"; +/* delete after time */ +"%d seconds(s)" = "%d seconde(s)"; + /* integrity error chat item */ "%d skipped message(s)" = "%d message·s sauté·s"; @@ -232,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nouvelles langues d'interface"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld seconde·s"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld secondes"; @@ -280,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 jour"; /* time interval */ @@ -289,12 +278,23 @@ /* No comment provided by engineer. */ "1 minute" = "1 minute"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 mois"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 semaine"; +/* delete after time */ +"1 year" = "1 an"; + +/* No comment provided by engineer. */ +"1-time link" = "Lien unique"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Le lien unique peut être utilisé *avec un seul contact* - partagez le en personne ou via n'importe quelle messagerie."; + /* No comment provided by engineer. */ "5 minutes" = "5 minutes"; @@ -328,6 +328,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Abandonner le changement d'adresse ?"; +/* No comment provided by engineer. */ +"About operators" = "À propos des opérateurs"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "À propos de SimpleX Chat"; @@ -338,10 +341,13 @@ "Accent" = "Principale"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Accepter"; +/* No comment provided by engineer. */ +"Accept conditions" = "Accepter les conditions"; + /* No comment provided by engineer. */ "Accept connection request?" = "Accepter la demande de connexion ?"; @@ -349,24 +355,39 @@ "Accept contact request from %@?" = "Accepter la demande de contact de %@ ?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Accepter en incognito"; /* call status */ "accepted call" = "appel accepté"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Conditions acceptées"; + +/* chat list item title */ +"accepted invitation" = "invitation acceptée"; + /* No comment provided by engineer. */ "Acknowledged" = "Reçu avec accusé de réception"; /* No comment provided by engineer. */ "Acknowledgement errors" = "Erreur d'accusé de réception"; +/* token status text */ +"Active" = "Actif"; + /* No comment provided by engineer. */ "Active connections" = "Connections actives"; /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d'autres personnes. La mise à jour du profil sera envoyée à vos contacts."; +/* No comment provided by engineer. */ +"Add friends" = "Ajouter des amis"; + +/* No comment provided by engineer. */ +"Add list" = "Ajouter une liste"; + /* No comment provided by engineer. */ "Add profile" = "Ajouter un profil"; @@ -376,12 +397,27 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Ajoutez des serveurs en scannant des codes QR."; +/* No comment provided by engineer. */ +"Add team members" = "Ajouter des membres à l'équipe"; + /* No comment provided by engineer. */ "Add to another device" = "Ajouter à un autre appareil"; +/* No comment provided by engineer. */ +"Add to list" = "Ajouter à la liste"; + /* No comment provided by engineer. */ "Add welcome message" = "Ajouter un message d'accueil"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Ajoutez les membres de votre équipe aux conversations."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Ajout de serveurs de médias et de fichiers"; + +/* No comment provided by engineer. */ +"Added message servers" = "Ajout de serveurs de messages"; + /* No comment provided by engineer. */ "Additional accent" = "Accent additionnel"; @@ -397,6 +433,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Le changement d'adresse sera annulé. L'ancienne adresse de réception sera utilisée."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Adresse ou lien unique ?"; + +/* No comment provided by engineer. */ +"Address settings" = "Paramètres de l'adresse"; + /* member role */ "admin" = "admin"; @@ -421,12 +463,18 @@ /* chat item text */ "agreeing encryption…" = "négociation du chiffrement…"; +/* No comment provided by engineer. */ +"All" = "Tout"; + /* No comment provided by engineer. */ "All app data is deleted." = "Toutes les données de l'application sont supprimées."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière !"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Tous les chats seront supprimés de la liste %@, et la liste sera supprimée."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Toutes les données sont effacées lorsqu'il est saisi."; @@ -439,6 +487,9 @@ /* feature role */ "all members" = "tous les membres"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Tous les messages seront supprimés - il n'est pas possible de revenir en arrière !"; @@ -451,6 +502,9 @@ /* profile dropdown */ "All profiles" = "Tous les profiles"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Tous les rapports seront archivés pour vous."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Tous vos contacts resteront connectés."; @@ -496,6 +550,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Autoriser la suppression irréversible de messages envoyés. (24 heures)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Permettre de signaler des messages aux modérateurs."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Permet l'envoi de fichiers et de médias."; @@ -550,6 +607,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "et %lld autres événements"; +/* report reason */ +"Another reason" = "Autre raison"; + /* No comment provided by engineer. */ "Answer call" = "Répondre à l'appel"; @@ -592,12 +652,30 @@ /* No comment provided by engineer. */ "Apply to" = "Appliquer à"; +/* No comment provided by engineer. */ +"Archive" = "Archiver"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiver les rapports %lld ?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archiver tous les rapports ?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiver et téléverser"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiver les contacts pour discuter plus tard."; +/* No comment provided by engineer. */ +"Archive report" = "Archiver le rapport"; + +/* No comment provided by engineer. */ +"Archive report?" = "Archiver le rapport ?"; + +/* swipe action */ +"Archive reports" = "Archiver les rapports"; + /* No comment provided by engineer. */ "Archived contacts" = "Contacts archivés"; @@ -679,6 +757,9 @@ /* No comment provided by engineer. */ "Better groups" = "Des groupes plus performants"; +/* No comment provided by engineer. */ +"Better groups performance" = "Meilleure performance des groupes"; + /* No comment provided by engineer. */ "Better message dates." = "Meilleures dates de messages."; @@ -691,6 +772,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Notifications améliorées"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Meilleure protection de la privacité et de la sécurité"; + /* No comment provided by engineer. */ "Better security ✅" = "Sécurité accrue ✅"; @@ -724,7 +808,8 @@ /* rcv group event chat item */ "blocked %@" = "%@ bloqué"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "bloqué par l'administrateur"; /* No comment provided by engineer. */ @@ -757,9 +842,21 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgare, finnois, thaïlandais et ukrainien - grâce aux utilisateurs et à [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) !"; +/* No comment provided by engineer. */ +"Business address" = "Adresse professionnelle"; + +/* No comment provided by engineer. */ +"Business chats" = "Discussions professionnelles"; + +/* No comment provided by engineer. */ +"Businesses" = "Entreprises"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "En utilisant SimpleX Chat, vous acceptez de :\n- n'envoyer que du contenu légal dans les groupes publics.\n- respecter les autres utilisateurs - pas de spam."; + /* No comment provided by engineer. */ "call" = "appeler"; @@ -800,7 +897,7 @@ "Can't message member" = "Impossible d'envoyer un message à ce membre"; /* alert action - alert button */ +alert button */ "Cancel" = "Annuler"; /* No comment provided by engineer. */ @@ -827,6 +924,12 @@ /* No comment provided by engineer. */ "Change" = "Changer"; +/* alert title */ +"Change automatic message deletion?" = "Modifier la suppression automatique des messages ?"; + +/* authentication reason */ +"Change chat profiles" = "Changer de profil de discussion"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Changer la phrase secrète de la base de données ?"; @@ -852,7 +955,7 @@ "Change self-destruct mode" = "Modifier le mode d'autodestruction"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Modifier le code d'autodestruction"; /* chat item text */ @@ -870,6 +973,15 @@ /* chat item text */ "changing address…" = "changement d'adresse…"; +/* No comment provided by engineer. */ +"Chat" = "Discussions"; + +/* No comment provided by engineer. */ +"Chat already exists" = "La discussion existe déjà"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "La discussion existe déjà !"; + /* No comment provided by engineer. */ "Chat colors" = "Couleurs de chat"; @@ -915,9 +1027,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Thème de chat"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé !"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière !"; + /* No comment provided by engineer. */ "Chats" = "Discussions"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Consulter les messages toutes les 20 minutes."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "Consulter les messages quand c'est possible."; + /* alert title */ "Check server address and try again." = "Vérifiez l'adresse du serveur et réessayez."; @@ -951,6 +1075,12 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Effacer la conversation ?"; +/* No comment provided by engineer. */ +"Clear group?" = "Vider le groupe ?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Vider ou supprimer le groupe ?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Effacer les notes privées ?"; @@ -966,6 +1096,9 @@ /* No comment provided by engineer. */ "colored" = "coloré"; +/* report reason */ +"Community guidelines violation" = "Infraction aux règles communautaires"; + /* server test step */ "Compare file" = "Comparer le fichier"; @@ -978,9 +1111,33 @@ /* No comment provided by engineer. */ "Completed" = "Complétées"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Conditions acceptées le : %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Les conditions sont acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Les conditions sont déjà acceptées pour ces opérateurs : **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Conditions d'utilisation"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Les conditions seront acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Les conditions seront acceptées le : %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Les conditions seront automatiquement acceptées pour les opérateurs activés le : %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Configurer les serveurs ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Configurer les opérateurs de serveur"; + /* No comment provided by engineer. */ "Confirm" = "Confirmer"; @@ -1011,6 +1168,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Confirmer la transmission"; +/* token status text */ +"Confirmed" = "Confirmé"; + /* server test step */ "Connect" = "Se connecter"; @@ -1110,6 +1270,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "État de la connexion et des serveurs."; +/* No comment provided by engineer. */ +"Connection blocked" = "Connexion bloquée"; + /* No comment provided by engineer. */ "Connection error" = "Erreur de connexion"; @@ -1119,12 +1282,24 @@ /* chat list item title (it should not be shown */ "connection established" = "connexion établie"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "La connexion est bloquée par l'opérateur du serveur :\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "La connexion n'est pas prête."; + /* No comment provided by engineer. */ "Connection notifications" = "Notifications de connexion"; /* No comment provided by engineer. */ "Connection request sent!" = "Demande de connexion envoyée !"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "La connexion nécessite une renégociation du cryptage."; + +/* No comment provided by engineer. */ +"Connection security" = "Sécurité des connexions"; + /* No comment provided by engineer. */ "Connection terminated" = "Connexion terminée"; @@ -1182,6 +1357,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter."; +/* blocking reason */ +"Content violates conditions of use" = "Le contenu enfreint les conditions d'utilisation"; + /* No comment provided by engineer. */ "Continue" = "Continuer"; @@ -1206,6 +1384,9 @@ /* No comment provided by engineer. */ "Create" = "Créer"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Créer un lien unique"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Création de groupes via un profil aléatoire."; @@ -1221,6 +1402,9 @@ /* No comment provided by engineer. */ "Create link" = "Créer un lien"; +/* No comment provided by engineer. */ +"Create list" = "Créer une liste"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Créer un nouveau profil sur [l'application de bureau](https://simplex.chat/downloads/). 💻"; @@ -1257,6 +1441,9 @@ /* No comment provided by engineer. */ "creator" = "créateur"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Le texte sur les conditions actuelles n'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien :"; + /* No comment provided by engineer. */ "Current Passcode" = "Code d'accès actuel"; @@ -1359,7 +1546,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Erreurs de déchiffrement"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "défaut (%@)"; /* No comment provided by engineer. */ @@ -1369,8 +1557,7 @@ "default (yes)" = "par défaut (oui)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Supprimer"; /* No comment provided by engineer. */ @@ -1394,12 +1581,21 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Supprimer et en informer le contact"; +/* No comment provided by engineer. */ +"Delete chat" = "Supprimer la discussion"; + +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Supprimer les messages de chat de votre appareil."; + /* No comment provided by engineer. */ "Delete chat profile" = "Supprimer le profil de chat"; /* No comment provided by engineer. */ "Delete chat profile?" = "Supprimer le profil du chat ?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Supprimer la discussion ?"; + /* No comment provided by engineer. */ "Delete connection" = "Supprimer la connexion"; @@ -1445,13 +1641,16 @@ /* No comment provided by engineer. */ "Delete link?" = "Supprimer le lien ?"; +/* alert title */ +"Delete list?" = "Supprimer la liste ?"; + /* No comment provided by engineer. */ "Delete member message?" = "Supprimer le message de ce membre ?"; /* No comment provided by engineer. */ "Delete message?" = "Supprimer le message ?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Supprimer les messages"; /* No comment provided by engineer. */ @@ -1475,6 +1674,9 @@ /* server test step */ "Delete queue" = "Supprimer la file d'attente"; +/* No comment provided by engineer. */ +"Delete report" = "Supprimer le rapport"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Supprimez jusqu'à 20 messages à la fois."; @@ -1505,6 +1707,9 @@ /* No comment provided by engineer. */ "Deletion errors" = "Erreurs de suppression"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "Distribués même quand Apple les oublie."; + /* No comment provided by engineer. */ "Delivery" = "Distribution"; @@ -1571,12 +1776,21 @@ /* chat feature */ "Direct messages" = "Messages directs"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "Les messages directs entre membres sont interdits dans cette discussion."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Les messages directs entre membres sont interdits dans ce groupe."; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Désactiver (conserver les remplacements)"; +/* alert title */ +"Disable automatic message deletion?" = "Désactiver la suppression automatique des messages ?"; + +/* alert button */ +"Disable delete messages" = "Désactiver la suppression des messages"; + /* No comment provided by engineer. */ "Disable for all" = "Désactiver pour tous"; @@ -1637,6 +1851,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "N'utilisez PAS SimpleX pour les appels d'urgence."; +/* No comment provided by engineer. */ +"Documents:" = "Documents:"; + /* No comment provided by engineer. */ "Don't create address" = "Ne pas créer d'adresse"; @@ -1644,13 +1861,19 @@ "Don't enable" = "Ne pas activer"; /* No comment provided by engineer. */ +"Don't miss important messages." = "Ne manquez pas les messages importants."; + +/* alert action */ "Don't show again" = "Ne plus afficher"; +/* No comment provided by engineer. */ +"Done" = "Terminé"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Rétrograder et ouvrir le chat"; /* alert button - chat item action */ +chat item action */ "Download" = "Télécharger"; /* No comment provided by engineer. */ @@ -1692,6 +1915,9 @@ /* No comment provided by engineer. */ "e2e encrypted" = "chiffré de bout en bout"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Notifications chiffrées E2E."; + /* chat item action */ "Edit" = "Modifier"; @@ -1704,12 +1930,15 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Activer (conserver les remplacements)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Activer la suppression automatique des messages ?"; /* No comment provided by engineer. */ "Enable camera access" = "Autoriser l'accès à la caméra"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Activez Flux dans les paramètres du réseau et des serveurs pour une meilleure confidentialité des métadonnées."; + /* No comment provided by engineer. */ "Enable for all" = "Activer pour tous"; @@ -1821,6 +2050,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "renégociation de chiffrement requise pour %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Renégociation du chiffrement en cours."; + /* No comment provided by engineer. */ "ended" = "terminé"; @@ -1869,12 +2101,18 @@ /* No comment provided by engineer. */ "Error aborting address change" = "Erreur lors de l'annulation du changement d'adresse"; +/* alert title */ +"Error accepting conditions" = "Erreur lors de la validation des conditions"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Erreur de validation de la demande de contact"; /* No comment provided by engineer. */ "Error adding member(s)" = "Erreur lors de l'ajout de membre·s"; +/* alert title */ +"Error adding server" = "Erreur lors de l'ajout du serveur"; + /* No comment provided by engineer. */ "Error changing address" = "Erreur de changement d'adresse"; @@ -1890,6 +2128,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Erreur lors du passage en mode incognito !"; +/* No comment provided by engineer. */ +"Error checking token status" = "Erreur lors de la vérification de l'état du jeton (token)"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard."; @@ -1902,6 +2143,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Erreur lors de la création du lien du groupe"; +/* alert title */ +"Error creating list" = "Erreur lors de la création de la liste"; + /* No comment provided by engineer. */ "Error creating member contact" = "Erreur lors de la création du contact du membre"; @@ -1911,6 +2155,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Erreur lors de la création du profil !"; +/* No comment provided by engineer. */ +"Error creating report" = "Erreur lors de la création du rapport"; + /* No comment provided by engineer. */ "Error decrypting file" = "Erreur lors du déchiffrement du fichier"; @@ -1959,6 +2206,9 @@ /* No comment provided by engineer. */ "Error joining group" = "Erreur lors de la liaison avec le groupe"; +/* alert title */ +"Error loading servers" = "Erreur de chargement des serveurs"; + /* No comment provided by engineer. */ "Error migrating settings" = "Erreur lors de la migration des paramètres"; @@ -1974,12 +2224,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Erreur de reconnexion des serveurs"; -/* No comment provided by engineer. */ +/* alert title */ +"Error registering for notifications" = "Erreur lors de l'inscription aux notifications"; + +/* alert title */ "Error removing member" = "Erreur lors de la suppression d'un membre"; +/* alert title */ +"Error reordering lists" = "Erreur lors de la réorganisation des listes"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Erreur de réinitialisation des statistiques"; +/* alert title */ +"Error saving chat list" = "Erreur lors de l'enregistrement de la liste des chats"; + /* No comment provided by engineer. */ "Error saving group profile" = "Erreur lors de la sauvegarde du profil de groupe"; @@ -1992,6 +2251,9 @@ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Erreur lors de l'enregistrement de la phrase de passe dans la keychain"; +/* alert title */ +"Error saving servers" = "Erreur d'enregistrement des serveurs"; + /* when migrating */ "Error saving settings" = "Erreur lors de l'enregistrement des paramètres"; @@ -2028,12 +2290,18 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Erreur de synchronisation de connexion"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Erreur lors du test de connexion au serveur"; + /* No comment provided by engineer. */ "Error updating group link" = "Erreur lors de la mise à jour du lien de groupe"; /* No comment provided by engineer. */ "Error updating message" = "Erreur lors de la mise à jour du message"; +/* alert title */ +"Error updating server" = "Erreur de mise à jour du serveur"; + /* No comment provided by engineer. */ "Error updating settings" = "Erreur lors de la mise à jour des paramètres"; @@ -2049,7 +2317,9 @@ /* No comment provided by engineer. */ "Error: " = "Erreur : "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Erreur : %@"; /* No comment provided by engineer. */ @@ -2061,11 +2331,11 @@ /* No comment provided by engineer. */ "Errors" = "Erreurs"; -/* No comment provided by engineer. */ -"Even when disabled in the conversation." = "Même s'il est désactivé dans la conversation."; +/* servers error */ +"Errors in servers configuration." = "Erreurs dans la configuration des serveurs."; /* No comment provided by engineer. */ -"event happened" = "event happened"; +"Even when disabled in the conversation." = "Même s'il est désactivé dans la conversation."; /* No comment provided by engineer. */ "Exit without saving" = "Quitter sans enregistrer"; @@ -2076,6 +2346,9 @@ /* No comment provided by engineer. */ "expired" = "expiré"; +/* token status text */ +"Expired" = "Expiré"; + /* No comment provided by engineer. */ "Export database" = "Exporter la base de données"; @@ -2100,18 +2373,30 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne !"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Suppression plus rapide des groupes."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Connexion plus rapide et messages plus fiables."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Envoi plus rapide des messages."; + /* swipe action */ "Favorite" = "Favoris"; /* No comment provided by engineer. */ +"Favorites" = "Favoris"; + +/* file error alert title */ "File error" = "Erreur de fichier"; /* alert message */ "File errors:\n%@" = "Erreurs de fichier :\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Le fichier est bloqué par l'opérateur du serveur :\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Fichier introuvable - le fichier a probablement été supprimé ou annulé."; @@ -2187,9 +2472,21 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Correction non prise en charge par un membre du groupe"; +/* servers error */ +"For chat profile %@:" = "Pour le profil de discussion %@ :"; + /* No comment provided by engineer. */ "For console" = "Pour la console"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux."; + +/* No comment provided by engineer. */ +"For private routing" = "Pour le routage privé"; + +/* No comment provided by engineer. */ +"For social media" = "Pour les réseaux sociaux"; + /* chat item action */ "Forward" = "Transférer"; @@ -2361,6 +2658,12 @@ /* time unit */ "hours" = "heures"; +/* No comment provided by engineer. */ +"How it affects privacy" = "L'impact sur la vie privée"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Comment il contribue à la protection de la vie privée"; + /* No comment provided by engineer. */ "How SimpleX works" = "Comment SimpleX fonctionne"; @@ -2565,6 +2868,9 @@ /* No comment provided by engineer. */ "Invite members" = "Inviter des membres"; +/* No comment provided by engineer. */ +"Invite to chat" = "Inviter à discuter"; + /* No comment provided by engineer. */ "Invite to group" = "Inviter au groupe"; @@ -2679,6 +2985,12 @@ /* swipe action */ "Leave" = "Quitter"; +/* No comment provided by engineer. */ +"Leave chat" = "Quitter la discussion"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Quitter la discussion ?"; + /* No comment provided by engineer. */ "Leave group" = "Quitter le groupe"; @@ -2775,12 +3087,18 @@ /* item status text */ "Member inactive" = "Membre inactif"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Le rôle du membre sera changé pour \"%@\". Tous les membres du groupe en seront informés."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Le rôle du membre sera changé pour \"%@\". Ce membre recevra une nouvelle invitation."; +/* No comment provided by engineer. */ +"Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la discussion - cela ne peut pas être annulé !"; + /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; @@ -2958,16 +3276,16 @@ /* No comment provided by engineer. */ "More reliable network connection." = "Connexion réseau plus fiable."; +/* No comment provided by engineer. */ +"More reliable notifications" = "Notifications plus fiables"; + /* item status description */ "Most likely this connection is deleted." = "Connexion probablement supprimée."; /* No comment provided by engineer. */ "Multiple chat profiles" = "Différents profils de chat"; -/* No comment provided by engineer. */ -"mute" = "muet"; - -/* swipe action */ +/* notification label action */ "Mute" = "Muet"; /* No comment provided by engineer. */ @@ -2982,19 +3300,25 @@ /* No comment provided by engineer. */ "Network connection" = "Connexion au réseau"; +/* No comment provided by engineer. */ +"Network decentralization" = "Décentralisation du réseau"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Problèmes de réseau - le message a expiré après plusieurs tentatives d'envoi."; /* No comment provided by engineer. */ "Network management" = "Gestion du réseau"; +/* No comment provided by engineer. */ +"Network operator" = "Opérateur de réseau"; + /* No comment provided by engineer. */ "Network settings" = "Paramètres réseau"; /* No comment provided by engineer. */ "Network status" = "État du réseau"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "jamais"; /* No comment provided by engineer. */ @@ -3015,6 +3339,9 @@ /* No comment provided by engineer. */ "New display name" = "Nouveau nom d'affichage"; +/* notification */ +"New events" = "Nouveaux événements"; + /* No comment provided by engineer. */ "New in %@" = "Nouveautés de la %@"; @@ -3036,6 +3363,9 @@ /* No comment provided by engineer. */ "New passphrase…" = "Nouvelle phrase secrète…"; +/* No comment provided by engineer. */ +"New server" = "Nouveau serveur"; + /* No comment provided by engineer. */ "New SOCKS credentials will be used every time you start the app." = "De nouveaux identifiants SOCKS seront utilisés chaque fois que vous démarrerez l'application."; @@ -3081,6 +3411,12 @@ /* No comment provided by engineer. */ "No info, try to reload" = "Pas d'info, essayez de recharger"; +/* servers error */ +"No media & file servers." = "Pas de serveurs de médias et de fichiers."; + +/* servers error */ +"No message servers." = "Pas de serveurs de messages."; + /* No comment provided by engineer. */ "No network connection" = "Pas de connexion au réseau"; @@ -3099,6 +3435,18 @@ /* No comment provided by engineer. */ "No received or sent files" = "Aucun fichier reçu ou envoyé"; +/* servers error */ +"No servers for private message routing." = "Pas de serveurs pour le routage privé des messages."; + +/* servers error */ +"No servers to receive files." = "Pas de serveurs pour recevoir des fichiers."; + +/* servers error */ +"No servers to receive messages." = "Pas de serveurs pour recevoir des messages."; + +/* servers error */ +"No servers to send files." = "Pas de serveurs pour envoyer des fichiers."; + /* copied message info in history */ "no text" = "aucun texte"; @@ -3120,6 +3468,9 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Les notifications sont désactivées !"; +/* No comment provided by engineer. */ +"Notifications privacy" = "Notifications sécurisées"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Désormais, les administrateurs peuvent :\n- supprimer les messages des membres.\n- désactiver des membres (rôle \"observateur\")"; @@ -3127,8 +3478,9 @@ "observer" = "observateur"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "off"; /* blur media */ @@ -3140,7 +3492,8 @@ /* feature offered item */ "offered %@: %@" = "propose %1$@ : %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3164,6 +3517,9 @@ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Les hôtes .onion ne seront pas utilisés."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Seuls les propriétaires peuvent modifier les préférences."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un **chiffrement de bout en bout à deux couches**."; @@ -3209,15 +3565,21 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Seul votre contact peut envoyer des messages vocaux."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Ouvrir"; +/* No comment provided by engineer. */ +"Open changes" = "Ouvrir les modifications"; + /* No comment provided by engineer. */ "Open chat" = "Ouvrir le chat"; /* authentication reason */ "Open chat console" = "Ouvrir la console du chat"; +/* No comment provided by engineer. */ +"Open conditions" = "Ouvrir les conditions"; + /* No comment provided by engineer. */ "Open group" = "Ouvrir le groupe"; @@ -3230,6 +3592,15 @@ /* No comment provided by engineer. */ "Opening app…" = "Ouverture de l'app…"; +/* No comment provided by engineer. */ +"Operator" = "Opérateur"; + +/* alert title */ +"Operator server" = "Serveur de l'opérateur"; + +/* No comment provided by engineer. */ +"Or import archive file" = "Ou importer un fichier d'archive"; + /* No comment provided by engineer. */ "Or paste archive link" = "Ou coller le lien de l'archive"; @@ -3242,6 +3613,9 @@ /* No comment provided by engineer. */ "Or show this code" = "Ou montrez ce code"; +/* No comment provided by engineer. */ +"Or to share privately" = "Ou à partager en privé"; + /* No comment provided by engineer. */ "other" = "autre"; @@ -3383,6 +3757,9 @@ /* No comment provided by engineer. */ "Preset server address" = "Adresse du serveur prédéfinie"; +/* No comment provided by engineer. */ +"Preset servers" = "Serveurs prédéfinis"; + /* No comment provided by engineer. */ "Preview" = "Aperçu"; @@ -3392,6 +3769,9 @@ /* No comment provided by engineer. */ "Privacy & security" = "Vie privée et sécurité"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Respect de la vie privée de vos clients."; + /* No comment provided by engineer. */ "Privacy redefined" = "La vie privée redéfinie"; @@ -3606,7 +3986,7 @@ "Reduced battery usage" = "Réduction de la consommation de batterie"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Rejeter"; /* No comment provided by engineer. */ @@ -3684,6 +4064,9 @@ /* chat item action */ "Reply" = "Répondre"; +/* chat list item title */ +"requested to connect" = "demande à se connecter"; + /* No comment provided by engineer. */ "Required" = "Requis"; @@ -3735,6 +4118,9 @@ /* chat item action */ "Reveal" = "Révéler"; +/* No comment provided by engineer. */ +"Review conditions" = "Vérifier les conditions"; + /* No comment provided by engineer. */ "Revoke" = "Révoquer"; @@ -3757,7 +4143,7 @@ "Safer groups" = "Groupes plus sûrs"; /* alert button - chat item action */ +chat item action */ "Save" = "Enregistrer"; /* alert button */ @@ -4021,6 +4407,9 @@ /* No comment provided by engineer. */ "Server" = "Serveur"; +/* alert message */ +"Server added to operator %@." = "Serveur ajouté à l'opérateur %@."; + /* No comment provided by engineer. */ "Server address" = "Adresse du serveur"; @@ -4030,6 +4419,15 @@ /* srv error text. */ "Server address is incompatible with network settings." = "L'adresse du serveur est incompatible avec les paramètres du réseau."; +/* alert title */ +"Server operator changed." = "L'opérateur du serveur a changé."; + +/* No comment provided by engineer. */ +"Server operators" = "Opérateurs de serveur"; + +/* alert title */ +"Server protocol changed." = "Le protocole du serveur a été modifié."; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "info sur la file d'attente du serveur : %1$@\n\ndernier message reçu : %2$@"; @@ -4109,15 +4507,21 @@ "Shape profile images" = "Images de profil modelable"; /* alert action - chat item action */ +chat item action */ "Share" = "Partager"; /* No comment provided by engineer. */ "Share 1-time link" = "Partager un lien unique"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "Partager un lien unique avec un ami"; + /* No comment provided by engineer. */ "Share address" = "Partager l'adresse"; +/* No comment provided by engineer. */ +"Share address publicly" = "Partager publiquement votre adresse"; + /* alert title */ "Share address with contacts?" = "Partager l'adresse avec vos contacts ?"; @@ -4130,6 +4534,9 @@ /* No comment provided by engineer. */ "Share profile" = "Partager le profil"; +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "Partagez votre adresse SimpleX sur les réseaux sociaux."; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "Partagez ce lien d'invitation unique"; @@ -4175,6 +4582,15 @@ /* No comment provided by engineer. */ "SimpleX Address" = "Adresse SimpleX"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n'importe quelle messagerie."; + +/* No comment provided by engineer. */ +"SimpleX address or 1-time link?" = "Adresse SimpleX ou lien unique ?"; + +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La sécurité de SimpleX Chat a été auditée par Trail of Bits."; @@ -4250,6 +4666,9 @@ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "L'importation a entraîné des erreurs non fatales :"; +/* alert message */ +"Some servers failed the test:\n%@" = "Certains serveurs ont échoué le test :\n%@"; + /* notification title */ "Somebody" = "Quelqu'un"; @@ -4352,6 +4771,9 @@ /* No comment provided by engineer. */ "Tap button " = "Appuyez sur le bouton "; +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement."; + /* No comment provided by engineer. */ "Tap to activate profile." = "Appuyez pour activer un profil."; @@ -4385,7 +4807,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Erreur de fichier temporaire"; /* server test failure */ @@ -4412,6 +4834,9 @@ /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "L'application peut vous avertir lorsque vous recevez des messages ou des demandes de contact - veuillez ouvrir les paramètres pour les activer."; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "L'application demandera de confirmer les téléchargements à partir de serveurs de fichiers inconnus (sauf .onion)."; @@ -4421,6 +4846,9 @@ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Le code scanné n'est pas un code QR de lien SimpleX."; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne."; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "La connexion que vous avez acceptée sera annulée !"; @@ -4458,7 +4886,10 @@ "The old database was not removed during the migration, it can be deleted." = "L'ancienne base de données n'a pas été supprimée lors de la migration, elle peut être supprimée."; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Le profil n'est partagé qu'avec vos contacts."; +"The same conditions will apply to operator **%@**." = "Les mêmes conditions s'appliquent à l'opérateur **%@**."; + +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "Le deuxième opérateur prédéfini de l'application !"; /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Le deuxième coche que nous avons manqué ! ✅"; @@ -4469,6 +4900,9 @@ /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "Les serveurs pour les nouvelles connexions de votre profil de chat actuel **%@**."; +/* No comment provided by engineer. */ +"The servers for new files of your current chat profile **%@**." = "Les serveurs pour les nouveaux fichiers de votre profil de chat actuel **%@**."; + /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Le texte collé n'est pas un lien SimpleX."; @@ -4478,6 +4912,9 @@ /* No comment provided by engineer. */ "Themes" = "Thèmes"; +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "Ces conditions s'appliquent également aux : **%@**."; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "Ces paramètres s'appliquent à votre profil actuel **%@**."; @@ -4541,6 +4978,9 @@ /* No comment provided by engineer. */ "To make a new connection" = "Pour établir une nouvelle connexion"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts."; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Pour préserver le fuseau horaire, les fichiers image/voix utilisent le système UTC."; @@ -4553,6 +4993,9 @@ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Pour protéger votre vie privée, au lieu d’IDs utilisés par toutes les autres plateformes, SimpleX a des IDs pour les queues de messages, distinctes pour chacun de vos contacts."; +/* No comment provided by engineer. */ +"To receive" = "Pour recevoir"; + /* No comment provided by engineer. */ "To record speech please grant permission to use Microphone." = "Si vous souhaitez enregistrer une conversation, veuillez autoriser l'utilisation du microphone."; @@ -4565,9 +5008,15 @@ /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page **Vos profils de chat**."; +/* No comment provided by engineer. */ +"To send" = "Pour envoyer"; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Pour prendre en charge les notifications push instantanées, la base de données du chat doit être migrée."; +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "Pour utiliser les serveurs de **%@**, acceptez les conditions d'utilisation."; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils."; @@ -4625,6 +5074,9 @@ /* rcv group event chat item */ "unblocked %@" = "%@ débloqué"; +/* No comment provided by engineer. */ +"Undelivered messages" = "Messages non distribués"; + /* No comment provided by engineer. */ "Unexpected migration state" = "État de la migration inattendu"; @@ -4682,10 +5134,7 @@ /* authentication reason */ "Unlock app" = "Déverrouiller l'app"; -/* No comment provided by engineer. */ -"unmute" = "démuter"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Démute"; /* No comment provided by engineer. */ @@ -4742,12 +5191,21 @@ /* No comment provided by engineer. */ "Use .onion hosts" = "Utiliser les hôtes .onions"; +/* No comment provided by engineer. */ +"Use %@" = "Utiliser %@"; + /* No comment provided by engineer. */ "Use chat" = "Utiliser le chat"; /* No comment provided by engineer. */ "Use current profile" = "Utiliser le profil actuel"; +/* No comment provided by engineer. */ +"Use for files" = "Utiliser pour les fichiers"; + +/* No comment provided by engineer. */ +"Use for messages" = "Utiliser pour les messages"; + /* No comment provided by engineer. */ "Use for new connections" = "Utiliser pour les nouvelles connexions"; @@ -4772,6 +5230,9 @@ /* No comment provided by engineer. */ "Use server" = "Utiliser ce serveur"; +/* No comment provided by engineer. */ +"Use servers" = "Utiliser les serveurs"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Utiliser les serveurs SimpleX Chat ?"; @@ -4856,9 +5317,15 @@ /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Vidéos et fichiers jusqu'à 1Go"; +/* No comment provided by engineer. */ +"View conditions" = "Voir les conditions"; + /* No comment provided by engineer. */ "View security code" = "Afficher le code de sécurité"; +/* No comment provided by engineer. */ +"View updated conditions" = "Voir les conditions mises à jour"; + /* chat feature */ "Visible history" = "Historique visible"; @@ -4940,6 +5407,9 @@ /* No comment provided by engineer. */ "when IP hidden" = "lorsque l'IP est masquée"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Lorsque plusieurs opérateurs sont activés, aucun d'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui."; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Lorsque vous partagez un profil incognito avec quelqu'un, ce profil sera utilisé pour les groupes auxquels il vous invite."; @@ -5003,6 +5473,9 @@ /* No comment provided by engineer. */ "You are already connected to %@." = "Vous êtes déjà connecté·e à %@ via ce lien."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Vous êtes déjà connecté avec %@."; + /* No comment provided by engineer. */ "You are already connecting to %@." = "Vous êtes déjà en train de vous connecter à %@."; @@ -5048,6 +5521,9 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Vous pouvez choisir de le modifier dans les paramètres d'apparence."; +/* No comment provided by engineer. */ +"You can configure servers via settings." = "Vous pouvez configurer les serveurs via les paramètres."; + /* No comment provided by engineer. */ "You can create it later" = "Vous pouvez la créer plus tard"; @@ -5072,6 +5548,9 @@ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Vous pouvez envoyer des messages à %@ à partir des contacts archivés."; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé."; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Vous pouvez configurer l'aperçu des notifications sur l'écran de verrouillage via les paramètres."; @@ -5192,6 +5671,9 @@ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu'ils sont actifs."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Vous ne recevrez plus de messages de ce groupe. L'historique du chat sera conservé."; @@ -5258,27 +5740,27 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Votre profil **%@** sera partagé."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Le profil n'est partagé qu'avec vos contacts."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Votre profil est stocké sur votre appareil et est seulement partagé avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Votre profil a été modifié. Si vous l'enregistrez, le profil mis à jour sera envoyé à tous vos contacts."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil."; - /* No comment provided by engineer. */ "Your random profile" = "Votre profil aléatoire"; /* No comment provided by engineer. */ "Your server address" = "Votre adresse de serveur"; +/* No comment provided by engineer. */ +"Your servers" = "Vos serveurs"; + /* No comment provided by engineer. */ "Your settings" = "Vos paramètres"; /* No comment provided by engineer. */ "Your SimpleX address" = "Votre adresse SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Vos serveurs SMP"; - diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 2ba51d1e13..9a1da01665 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (másolható)"; @@ -14,29 +8,17 @@ "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tag).\n- gyorsabb és stabilabb."; /* No comment provided by engineer. */ -"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- valamivel jobb csoportok.\n- és még sok más!"; +"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más!"; /* No comment provided by engineer. */ -"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- értesíti az ismerősöket a törlésről (nem kötelező)\n- profil nevek szóközökkel\n- és még sok más!"; +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- partnerek értesítése a törlésről (nem kötelező)\n- profilnevek szóközökkel\n- és még sok más!"; /* No comment provided by engineer. */ -"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 perc hosszúságú hangüzenetek.\n- egyedi üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; - -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; +"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; /* No comment provided by engineer. */ "!1 colored!" = "!1 színezett!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(új)"; @@ -44,10 +26,7 @@ "(this device v%@)" = "(ez az eszköz: v%@)"; /* No comment provided by engineer. */ -")" = ")"; - -/* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Hozzájárulás](https://github.com/simplex-chat/simplex-chat#contribute)"; +"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Közreműködés](https://github.com/simplex-chat/simplex-chat#contribute)"; /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Küldjön nekünk e-mailt](mailto:chat@simplex.chat)"; @@ -56,7 +35,7 @@ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Csillagozás a GitHubon](https://github.com/simplex-chat/simplex-chat)"; /* No comment provided by engineer. */ -"**Create 1-time link**: to create and share a new invitation link." = "**Ismerős hozzáadása:** új meghívó-hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; +"**Create 1-time link**: to create and share a new invitation link." = "**Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; /* No comment provided by engineer. */ "**Create group**: to create a new group." = "**Csoport létrehozása:** új csoport létrehozásához."; @@ -68,19 +47,19 @@ "**e2e encrypted** video call" = "**e2e titkosított** videóhívás"; /* No comment provided by engineer. */ -"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken megosztásra kerül a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van."; +"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van."; /* No comment provided by engineer. */ "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." = "**Legprivátabb:** ne használja a SimpleX Chat értesítési kiszolgálót, rendszeresen ellenőrizze az üzeneteket a háttérben (attól függően, hogy milyen gyakran használja az alkalmazást)."; /* No comment provided by engineer. */ -"**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését."; +"**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM tudja visszaállítani vagy megváltoztatni jelmondatát, ha elveszíti azt."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti."; /* No comment provided by engineer. */ -"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések elküldésre kerülnek a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; +"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; /* No comment provided by engineer. */ "**Scan / Paste link**: to connect via a link you received." = "**Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz."; @@ -89,7 +68,7 @@ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; /* No comment provided by engineer. */ -"**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum eltávolításra kerül."; +"**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum el lesz távolítva."; /* No comment provided by engineer. */ "*bold*" = "\\*félkövér*"; @@ -101,7 +80,7 @@ "## History" = "## Előzmények"; /* copied message info */ -"## In reply to" = "## Válaszul erre:"; +"## In reply to" = "## Válaszul erre"; /* No comment provided by engineer. */ "#secret#" = "#titok#"; @@ -140,7 +119,7 @@ "%@ is connected!" = "%@ kapcsolódott!"; /* No comment provided by engineer. */ -"%@ is not verified" = "%@ nem hitelesített"; +"%@ is not verified" = "%@ nincs hitelesítve"; /* No comment provided by engineer. */ "%@ is verified" = "%@ hitelesítve"; @@ -176,7 +155,7 @@ "%d file(s) are still being downloaded." = "%d fájl letöltése még folyamatban van."; /* forward confirmation reason */ -"%d file(s) failed to download." = "%d fájlt nem sikerült letölteni."; +"%d file(s) failed to download." = "Nem sikerült letölteni %d fájlt."; /* forward confirmation reason */ "%d file(s) were deleted." = "%d fájl törölve lett."; @@ -199,6 +178,9 @@ /* time interval */ "%d sec" = "%d mp"; +/* delete after time */ +"%d seconds(s)" = "%d másodperc"; + /* integrity error chat item */ "%d skipped message(s)" = "%d üzenet kihagyva"; @@ -212,10 +194,10 @@ "%lld %@" = "%lld %@"; /* No comment provided by engineer. */ -"%lld contact(s) selected" = "%lld ismerős kiválasztva"; +"%lld contact(s) selected" = "%lld partner kijelölve"; /* No comment provided by engineer. */ -"%lld file(s) with total size of %@" = "%lld fájl, amely(ek)nek teljes mérete: %@"; +"%lld file(s) with total size of %@" = "%lld fájl, %@ összméretben"; /* No comment provided by engineer. */ "%lld group events" = "%lld csoportesemény"; @@ -230,7 +212,7 @@ "%lld messages blocked by admin" = "%lld üzenetet letiltott az adminisztrátor"; /* No comment provided by engineer. */ -"%lld messages marked deleted" = "%lld törlésre megjelölt üzenet"; +"%lld messages marked deleted" = "%lld üzenet megjelölve törlésre"; /* No comment provided by engineer. */ "%lld messages moderated by %@" = "%@ %lld üzenetet moderált"; @@ -242,34 +224,31 @@ "%lld new interface languages" = "%lld új kezelőfelületi nyelv"; /* No comment provided by engineer. */ -"%lld second(s)" = "%lld másodperc"; +"%lld seconds" = "%lld mp"; /* No comment provided by engineer. */ -"%lld seconds" = "%lld másodperc"; +"%lldd" = "%lldn"; /* No comment provided by engineer. */ -"%lldd" = "%lldd"; - -/* No comment provided by engineer. */ -"%lldh" = "%lldh"; +"%lldh" = "%lldó"; /* No comment provided by engineer. */ "%lldk" = "%lldk"; /* No comment provided by engineer. */ -"%lldm" = "%lldm"; +"%lldm" = "%lldp"; /* No comment provided by engineer. */ -"%lldmth" = "%lldmth"; +"%lldmth" = "%lldh"; /* No comment provided by engineer. */ -"%llds" = "%llds"; +"%llds" = "%lldmp"; /* No comment provided by engineer. */ -"%lldw" = "%lldw"; +"%lldw" = "%lldhét"; /* No comment provided by engineer. */ -"%u messages failed to decrypt." = "%u üzenet visszafejtése sikertelen."; +"%u messages failed to decrypt." = "Nem sikerült visszafejteni %u üzenetet."; /* No comment provided by engineer. */ "%u messages skipped." = "%u üzenet kihagyva."; @@ -289,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 nap"; /* time interval */ @@ -298,17 +278,22 @@ /* No comment provided by engineer. */ "1 minute" = "1 perc"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 hónap"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 hét"; -/* No comment provided by engineer. */ -"1-time link" = "Egyszer használható meghívó-hivatkozás"; +/* delete after time */ +"1 year" = "1 év"; /* No comment provided by engineer. */ -"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* - személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; +"1-time link" = "Egyszer használható meghívó"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható."; /* No comment provided by engineer. */ "5 minutes" = "5 perc"; @@ -320,61 +305,73 @@ "30 seconds" = "30 másodperc"; /* No comment provided by engineer. */ -"A few more things" = "Még néhány dolog"; +"A few more things" = "Néhány további dolog"; /* notification title */ -"A new contact" = "Egy új ismerős"; +"A new contact" = "Egy új partner"; /* No comment provided by engineer. */ -"A new random profile will be shared." = "Egy új, véletlenszerű profil kerül megosztásra."; +"A new random profile will be shared." = "Egy új, véletlenszerű profil lesz megosztva."; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "**Az összes csevegési profiljához az alkalmazásban** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**Az összes ismerőséhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\n**Megjegyzés:** ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**Az összes partneréhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\n**Megjegyzés:** ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet."; /* No comment provided by engineer. */ "Abort" = "Megszakítás"; /* No comment provided by engineer. */ -"Abort changing address" = "Címváltoztatás megszakítása"; +"Abort changing address" = "Cím módosításának megszakítása"; /* No comment provided by engineer. */ -"Abort changing address?" = "Címváltoztatás megszakítása??"; +"Abort changing address?" = "Megszakítja a cím módosítását?"; /* No comment provided by engineer. */ "About operators" = "Az üzemeltetőkről"; /* No comment provided by engineer. */ -"About SimpleX Chat" = "SimpleX Chat névjegye"; +"About SimpleX Chat" = "A SimpleX Chat névjegye"; /* No comment provided by engineer. */ "above, then choose:" = "gombra fent, majd válassza ki:"; /* No comment provided by engineer. */ -"Accent" = "Kiemelés"; +"Accent" = "Kiemelőszín"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Elfogadás"; +/* alert action */ +"Accept as member" = "Befogadás tagként"; + +/* alert action */ +"Accept as observer" = "Befogadás megfigyelőként"; + /* No comment provided by engineer. */ "Accept conditions" = "Feltételek elfogadása"; /* No comment provided by engineer. */ -"Accept connection request?" = "Kapcsolatkérés elfogadása?"; +"Accept connection request?" = "Elfogadja a meghívási kérést?"; /* notification body */ -"Accept contact request from %@?" = "Elfogadja %@ kapcsolatkérését?"; +"Accept contact request from %@?" = "Elfogadja %@ meghívási kérését?"; /* accept contact request via notification - swipe action */ -"Accept incognito" = "Fogadás inkognitóban"; +swipe action */ +"Accept incognito" = "Elfogadás inkognitóban"; + +/* alert title */ +"Accept member" = "Tag befogadása"; + +/* rcv group event chat item */ +"accepted %@" = "befogadta őt: %@"; /* call status */ -"accepted call" = "elfogadott hívás"; +"accepted call" = "fogadott hívás"; /* No comment provided by engineer. */ "Accepted conditions" = "Elfogadott feltételek"; @@ -382,21 +379,30 @@ /* chat list item title */ "accepted invitation" = "elfogadott meghívó"; -/* No comment provided by engineer. */ -"Acknowledged" = "Nyugtázva"; +/* rcv group event chat item */ +"accepted you" = "befogadta Önt"; /* No comment provided by engineer. */ -"Acknowledgement errors" = "Nyugtázott hibák"; +"Acknowledged" = "Visszaigazolt"; + +/* No comment provided by engineer. */ +"Acknowledgement errors" = "Visszaigazolási hibák"; + +/* token status text */ +"Active" = "Aktív"; /* No comment provided by engineer. */ "Active connections" = "Aktív kapcsolatok száma"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés elküldésre kerül az ismerősei számára."; +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára."; /* No comment provided by engineer. */ "Add friends" = "Barátok hozzáadása"; +/* No comment provided by engineer. */ +"Add list" = "Lista hozzáadása"; + /* No comment provided by engineer. */ "Add profile" = "Profil hozzáadása"; @@ -407,16 +413,19 @@ "Add servers by scanning QR codes." = "Kiszolgáló hozzáadása QR-kód beolvasásával."; /* No comment provided by engineer. */ -"Add team members" = "Csapattagok hozzáadása"; +"Add team members" = "Munkatársak hozzáadása"; /* No comment provided by engineer. */ "Add to another device" = "Hozzáadás egy másik eszközhöz"; +/* No comment provided by engineer. */ +"Add to list" = "Hozzáadás listához"; + /* No comment provided by engineer. */ "Add welcome message" = "Üdvözlőüzenet hozzáadása"; /* No comment provided by engineer. */ -"Add your team members to the conversations." = "Adja hozzá csapattagjait a beszélgetésekhez."; +"Add your team members to the conversations." = "Adja hozzá a munkatársait a beszélgetésekhez."; /* No comment provided by engineer. */ "Added media & file servers" = "Hozzáadott média- és fájlkiszolgálók"; @@ -425,22 +434,22 @@ "Added message servers" = "Hozzáadott üzenetkiszolgálók"; /* No comment provided by engineer. */ -"Additional accent" = "További kiemelés"; +"Additional accent" = "További kiemelőszín"; /* No comment provided by engineer. */ -"Additional accent 2" = "További kiemelés 2"; +"Additional accent 2" = "További kiemelőszín 2"; /* No comment provided by engineer. */ -"Additional secondary" = "További másodlagos"; +"Additional secondary" = "További másodlagos szín"; /* No comment provided by engineer. */ "Address" = "Cím"; /* No comment provided by engineer. */ -"Address change will be aborted. Old receiving address will be used." = "A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra."; +"Address change will be aborted. Old receiving address will be used." = "A cím módosítása meg fog szakadni. A régi fogadási cím lesz használva."; /* No comment provided by engineer. */ -"Address or 1-time link?" = "Cím vagy egyszer használható meghívó-hivatkozás?"; +"Address or 1-time link?" = "Cím vagy egyszer használható meghívó?"; /* No comment provided by engineer. */ "Address settings" = "Címbeállítások"; @@ -455,7 +464,7 @@ "Admins can block a member for all." = "Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak."; /* No comment provided by engineer. */ -"Admins can create the links to join groups." = "Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való kapcsolódáshoz."; +"Admins can create the links to join groups." = "Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz."; /* No comment provided by engineer. */ "Advanced network settings" = "Speciális hálózati beállítások"; @@ -469,17 +478,26 @@ /* chat item text */ "agreeing encryption…" = "titkosítás elfogadása…"; +/* member criteria value */ +"all" = "összes"; + +/* No comment provided by engineer. */ +"All" = "Összes"; + /* No comment provided by engineer. */ "All app data is deleted." = "Az összes alkalmazásadat törölve."; /* No comment provided by engineer. */ -"All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza!"; +"All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza!"; + +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Az összes csevegés el lesz távolítva a(z) %@ nevű listáról, és a lista is törölve lesz."; /* No comment provided by engineer. */ -"All data is erased when it is entered." = "A jelkód megadása után az összes adat törlésre kerül."; +"All data is erased when it is entered." = "A jelkód megadása után az összes adat törölve lesz."; /* No comment provided by engineer. */ -"All data is kept private on your device." = "Az összes adat biztonságban van az eszközén."; +"All data is kept private on your device." = "Az összes adat privát módon van tárolva az eszközén."; /* No comment provided by engineer. */ "All group members will remain connected." = "Az összes csoporttag kapcsolatban marad."; @@ -488,52 +506,58 @@ "all members" = "összes tag"; /* No comment provided by engineer. */ -"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenetet és fájlt **végpontok közötti titkosítással** küldi, a közvetlen üzenetekben pedig kvantumrezisztens biztonsággal."; +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek."; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone!" = "Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza!"; +"All messages will be deleted - this cannot be undone!" = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek."; +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek."; /* No comment provided by engineer. */ -"All new messages from %@ will be hidden!" = "Az összes új üzenet elrejtésre kerül tőle: %@!"; +"All new messages from %@ will be hidden!" = "%@ összes új üzenete el lesz rejtve!"; /* profile dropdown */ "All profiles" = "Összes profil"; /* No comment provided by engineer. */ -"All your contacts will remain connected." = "Az összes ismerősével kapcsolatban marad."; +"All reports will be archived for you." = "Az összes jelentés archiválva lesz az Ön számára."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél."; +"All servers" = "Összes kiszolgáló"; /* No comment provided by engineer. */ -"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra."; +"All your contacts will remain connected." = "Az összes partnerével kapcsolatban marad."; + +/* No comment provided by engineer. */ +"All your contacts will remain connected. Profile update will be sent to your contacts." = "A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; + +/* No comment provided by engineer. */ +"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra."; /* No comment provided by engineer. */ "Allow" = "Engedélyezés"; /* No comment provided by engineer. */ -"Allow calls only if your contact allows them." = "A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; +"Allow calls only if your contact allows them." = "A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ -"Allow calls?" = "Hívások engedélyezése?"; +"Allow calls?" = "Engedélyezi a hívásokat?"; /* No comment provided by engineer. */ -"Allow disappearing messages only if your contact allows it to you." = "Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi az Ön számára."; +"Allow disappearing messages only if your contact allows it to you." = "Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi az Ön számára."; /* No comment provided by engineer. */ "Allow downgrade" = "Visszafejlesztés engedélyezése"; /* No comment provided by engineer. */ -"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra)"; +"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra)"; /* No comment provided by engineer. */ -"Allow message reactions only if your contact allows them." = "Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; +"Allow message reactions only if your contact allows them." = "A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ -"Allow message reactions." = "Üzenetreakciók engedélyezése."; +"Allow message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van."; /* No comment provided by engineer. */ "Allow sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között engedélyezve van."; @@ -545,37 +569,40 @@ "Allow sharing" = "Megosztás engedélyezése"; /* No comment provided by engineer. */ -"Allow to irreversibly delete sent messages. (24 hours)" = "Elküldött üzenetek végleges törlésének engedélyezése. (24 óra)"; +"Allow to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra)"; /* No comment provided by engineer. */ -"Allow to send files and media." = "Fájlok és médiatartalmak küldésének engedélyezése."; +"Allow to report messsages to moderators." = "Az üzenetek jelentése a moderátorok felé engedélyezve van."; + +/* No comment provided by engineer. */ +"Allow to send files and media." = "A fájlok- és a médiatartalmak küldése engedélyezve van."; /* No comment provided by engineer. */ "Allow to send SimpleX links." = "A SimpleX-hivatkozások küldése engedélyezve van."; /* No comment provided by engineer. */ -"Allow to send voice messages." = "Hangüzenetek küldésének engedélyezése."; +"Allow to send voice messages." = "A hangüzenetek küldése engedélyezve van."; /* No comment provided by engineer. */ -"Allow voice messages only if your contact allows them." = "A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; +"Allow voice messages only if your contact allows them." = "A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ -"Allow voice messages?" = "Hangüzenetek engedélyezése?"; +"Allow voice messages?" = "Engedélyezi a hangüzeneteket?"; /* No comment provided by engineer. */ -"Allow your contacts adding message reactions." = "Az üzenetreakciók küldése engedélyezve van az ismerősei számára."; +"Allow your contacts adding message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ -"Allow your contacts to call you." = "A hívások kezdeményezése engedélyezve van az ismerősei számára."; +"Allow your contacts to call you." = "A hívások kezdeményezése engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ -"Allow your contacts to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra)"; +"Allow your contacts to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra)"; /* No comment provided by engineer. */ -"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldésének engedélyezése az ismerősei számára."; +"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldésének engedélyezése a partnerei számára."; /* No comment provided by engineer. */ -"Allow your contacts to send voice messages." = "A hangüzenetek küldése engedélyezve van az ismerősei számára."; +"Allow your contacts to send voice messages." = "A hangüzenetek küldése engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ "Already connected?" = "Már kapcsolódott?"; @@ -593,14 +620,17 @@ "Always use private routing." = "Mindig használjon privát útválasztást."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig használjon közvetítő-kiszolgálót"; +"Always use relay" = "Mindig használjon továbbítókiszolgálót"; /* No comment provided by engineer. */ -"An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; +"An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; /* No comment provided by engineer. */ "and %lld other events" = "és további %lld esemény"; +/* report reason */ +"Another reason" = "Egyéb indoklás"; + /* No comment provided by engineer. */ "Answer call" = "Hívás fogadása"; @@ -608,7 +638,7 @@ "Anybody can host servers." = "Bárki üzemeltethet kiszolgálókat."; /* No comment provided by engineer. */ -"App build: %@" = "Az alkalmazás build száma: %@"; +"App build: %@" = "Az alkalmazás összeállítási száma: %@"; /* No comment provided by engineer. */ "App data migration" = "Alkalmazásadatok átköltöztetése"; @@ -616,6 +646,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével)."; +/* No comment provided by engineer. */ +"App group:" = "Alkalmazáscsoport:"; + /* No comment provided by engineer. */ "App icon" = "Alkalmazásikon"; @@ -623,16 +656,16 @@ "App passcode" = "Alkalmazás jelkód"; /* No comment provided by engineer. */ -"App passcode is replaced with self-destruct passcode." = "Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal."; +"App passcode is replaced with self-destruct passcode." = "Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal."; /* No comment provided by engineer. */ "App session" = "Alkalmazás munkamenete"; /* No comment provided by engineer. */ -"App version" = "Alkalmazás verzió"; +"App version" = "Az alkalmazás verziója"; /* No comment provided by engineer. */ -"App version: v%@" = "Alkalmazás verzió: v%@"; +"App version: v%@" = "Az alkalmazás verziója: v%@"; /* No comment provided by engineer. */ "Appearance" = "Megjelenés"; @@ -643,20 +676,41 @@ /* No comment provided by engineer. */ "Apply to" = "Alkalmazás erre"; +/* No comment provided by engineer. */ +"Archive" = "Archívum"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archivál %lld jelentést?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archiválja az összes jelentést?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiválás és feltöltés"; /* No comment provided by engineer. */ -"Archive contacts to chat later." = "Az ismerősök archiválása a későbbi csevegéshez."; +"Archive contacts to chat later." = "A partnerek archiválása a későbbi csevegéshez."; /* No comment provided by engineer. */ -"Archived contacts" = "Archivált ismerősök"; +"Archive report" = "Jelentés archiválása"; + +/* No comment provided by engineer. */ +"Archive report?" = "Archiválja a jelentést?"; + +/* swipe action */ +"Archive reports" = "Jelentések archiválása"; + +/* No comment provided by engineer. */ +"Archived contacts" = "Archivált partnerek"; + +/* No comment provided by engineer. */ +"archived report" = "archivált jelentés"; /* No comment provided by engineer. */ "Archiving database" = "Adatbázis archiválása"; /* No comment provided by engineer. */ -"Attach" = "Csatolás"; +"Attach" = "Mellékelés"; /* No comment provided by engineer. */ "attempts" = "próbálkozások"; @@ -671,7 +725,7 @@ "audio call (not e2e encrypted)" = "hanghívás (nem e2e titkosított)"; /* chat feature */ -"Audio/video calls" = "Hang-/videóhívások"; +"Audio/video calls" = "Hang- és videóhívások"; /* No comment provided by engineer. */ "Audio/video calls are prohibited." = "A hívások kezdeményezése le van tiltva ebben a csevegésben."; @@ -695,7 +749,7 @@ "Auto-accept" = "Automatikus elfogadás"; /* No comment provided by engineer. */ -"Auto-accept contact requests" = "Kapcsolatkérések automatikus elfogadása"; +"Auto-accept contact requests" = "Meghívási kérések automatikus elfogadása"; /* No comment provided by engineer. */ "Auto-accept images" = "Képek automatikus elfogadása"; @@ -710,13 +764,13 @@ "Background" = "Háttér"; /* No comment provided by engineer. */ -"Bad desktop address" = "Hibás számítógép cím"; +"Bad desktop address" = "Érvénytelen számítógépcím"; /* integrity error chat item */ -"bad message hash" = "hibás az üzenet hasító értéke"; +"bad message hash" = "érvénytelen az üzenet hasítóértéke"; /* No comment provided by engineer. */ -"Bad message hash" = "Hibás az üzenet hasító értéke"; +"Bad message hash" = "Érvénytelen az üzenet hasítóértéke"; /* integrity error chat item */ "bad message ID" = "téves üzenet ID"; @@ -728,13 +782,16 @@ "Better calls" = "Továbbfejlesztett hívásélmény"; /* No comment provided by engineer. */ -"Better groups" = "Javított csoportok"; +"Better groups" = "Továbbfejlesztett csoportok"; + +/* No comment provided by engineer. */ +"Better groups performance" = "Továbbfejlesztett, gyorsabb csoportok"; /* No comment provided by engineer. */ "Better message dates." = "Továbbfejlesztett üzenetdátumok."; /* No comment provided by engineer. */ -"Better messages" = "Jobb üzenetek"; +"Better messages" = "Továbbfejlesztett üzenetek"; /* No comment provided by engineer. */ "Better networking" = "Jobb hálózatkezelés"; @@ -742,6 +799,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Továbbfejlesztett értesítések"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Továbbfejlesztett adatvédelem és biztonság"; + /* No comment provided by engineer. */ "Better security ✅" = "Továbbfejlesztett biztonság ✅"; @@ -755,31 +815,32 @@ "Block" = "Letiltás"; /* No comment provided by engineer. */ -"Block for all" = "Letiltás az összes tag számára"; +"Block for all" = "Letiltás"; /* No comment provided by engineer. */ "Block group members" = "Csoporttagok letiltása"; /* No comment provided by engineer. */ -"Block member" = "Tag letiltása"; +"Block member" = "Letiltás"; /* No comment provided by engineer. */ -"Block member for all?" = "Az összes tag számára letiltja ezt a tagot?"; +"Block member for all?" = "Az összes tag számára letiltja a tagot?"; /* No comment provided by engineer. */ -"Block member?" = "Tag letiltása?"; +"Block member?" = "Letiltja a tagot?"; /* marked deleted chat item preview text */ "blocked" = "letiltva"; /* rcv group event chat item */ -"blocked %@" = "letiltotta %@-t"; +"blocked %@" = "letiltotta őt: %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "letiltva az adminisztrátor által"; /* No comment provided by engineer. */ -"Blocked by admin" = "Az adminisztrátor letiltotta"; +"Blocked by admin" = "Letiltva az adminisztrátor által"; /* No comment provided by engineer. */ "Blur for better privacy." = "Elhomályosítás a jobb adatvédelemért."; @@ -791,10 +852,10 @@ "bold" = "félkövér"; /* No comment provided by engineer. */ -"Both you and your contact can add message reactions." = "Mindkét fél is hozzáadhat üzenetreakciókat."; +"Both you and your contact can add message reactions." = "Mindkét fél hozzáadhat az üzenetekhez reakciókat."; /* No comment provided by engineer. */ -"Both you and your contact can irreversibly delete sent messages. (24 hours)" = "Mindkét fél törölheti véglegesen az elküldött üzeneteket. (24 óra)"; +"Both you and your contact can irreversibly delete sent messages. (24 hours)" = "Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra)"; /* No comment provided by engineer. */ "Both you and your contact can make calls." = "Mindkét fél tud hívásokat kezdeményezni."; @@ -814,9 +875,15 @@ /* No comment provided by engineer. */ "Business chats" = "Üzleti csevegések"; +/* No comment provided by engineer. */ +"Businesses" = "Üzleti"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "A SimpleX Chat használatával Ön elfogadja, hogy:\n- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban.\n- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek."; + /* No comment provided by engineer. */ "call" = "hívás"; @@ -842,22 +909,25 @@ "Camera not available" = "A kamera nem elérhető"; /* No comment provided by engineer. */ -"Can't call contact" = "Nem lehet felhívni az ismerőst"; +"Can't call contact" = "Nem lehet felhívni a partnert"; /* No comment provided by engineer. */ "Can't call member" = "Nem lehet felhívni a tagot"; /* No comment provided by engineer. */ -"Can't invite contact!" = "Nem lehet meghívni az ismerőst!"; +"Can't invite contact!" = "Nem lehet meghívni a partnert!"; /* No comment provided by engineer. */ -"Can't invite contacts!" = "Nem lehet meghívni az ismerősöket!"; +"Can't invite contacts!" = "Nem lehet meghívni a partnereket!"; /* No comment provided by engineer. */ "Can't message member" = "Nem lehet üzenetet küldeni a tagnak"; +/* No comment provided by engineer. */ +"can't send messages" = "nem lehet üzeneteket küldeni"; + /* alert action - alert button */ +alert button */ "Cancel" = "Mégse"; /* No comment provided by engineer. */ @@ -876,59 +946,62 @@ "Cannot receive file" = "Nem lehet fogadni a fájlt"; /* snd error text */ -"Capacity exceeded - recipient did not receive previously sent messages." = "Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket."; +"Capacity exceeded - recipient did not receive previously sent messages." = "Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket."; /* No comment provided by engineer. */ "Cellular" = "Mobilhálózat"; /* No comment provided by engineer. */ -"Change" = "Változtatás"; +"Change" = "Módosítás"; + +/* alert title */ +"Change automatic message deletion?" = "Módosítja az automatikus üzenettörlést?"; /* authentication reason */ -"Change chat profiles" = "Csevegési profilok megváltoztatása"; +"Change chat profiles" = "Csevegési profilok módosítása"; /* No comment provided by engineer. */ -"Change database passphrase?" = "Adatbázis-jelmondat megváltoztatása?"; +"Change database passphrase?" = "Módosítja az adatbázis jelmondatát?"; /* authentication reason */ -"Change lock mode" = "Zárolási mód megváltoztatása"; +"Change lock mode" = "Zárolási mód módosítása"; /* No comment provided by engineer. */ -"Change member role?" = "Tag szerepkörének megváltoztatása?"; +"Change member role?" = "Módosítja a tag szerepkörét?"; /* authentication reason */ -"Change passcode" = "Jelkód megváltoztatása"; +"Change passcode" = "Jelkód módosítása"; /* No comment provided by engineer. */ -"Change receiving address" = "A fogadó cím megváltoztatása"; +"Change receiving address" = "Fogadási cím módosítása"; /* No comment provided by engineer. */ -"Change receiving address?" = "Megváltoztatja a fogadó címet?"; +"Change receiving address?" = "Módosítja a fogadási címet?"; /* No comment provided by engineer. */ -"Change role" = "Szerepkör megváltoztatása"; +"Change role" = "Szerepkör módosítása"; /* authentication reason */ -"Change self-destruct mode" = "Önmegsemmisítő mód megváltoztatása"; +"Change self-destruct mode" = "Önmegsemmisítő-mód módosítása"; /* authentication reason - set passcode view */ -"Change self-destruct passcode" = "Önmegsemmisító jelkód megváltoztatása"; +set passcode view */ +"Change self-destruct passcode" = "Önmegsemmisítő-jelkód módosítása"; /* chat item text */ -"changed address for you" = "cím megváltoztatva"; +"changed address for you" = "módosította a címet az Ön számára"; /* rcv group event chat item */ -"changed role of %@ to %@" = "%1$@ szerepkörét megváltoztatta erre: %2$@"; +"changed role of %@ to %@" = "a következőre módosította %1$@ szerepkörét: „%2$@”"; /* rcv group event chat item */ -"changed your role to %@" = "megváltoztatta az Ön szerepkörét erre: %@"; +"changed your role to %@" = "a következőre módosította az Ön szerepkörét: „%@”"; /* chat item text */ -"changing address for %@…" = "cím megváltoztatása nála: %@…"; +"changing address for %@…" = "cím módosítása %@ számára…"; /* chat item text */ -"changing address…" = "cím megváltoztatása…"; +"changing address…" = "cím módosítása…"; /* No comment provided by engineer. */ "Chat" = "Csevegés"; @@ -961,13 +1034,13 @@ "Chat is running" = "A csevegés fut"; /* No comment provided by engineer. */ -"Chat is stopped" = "A csevegés leállt"; +"Chat is stopped" = "A csevegés megállt"; /* No comment provided by engineer. */ -"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt."; +"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt."; /* No comment provided by engineer. */ -"Chat list" = "Csevegőlista"; +"Chat list" = "Csevegési lista"; /* No comment provided by engineer. */ "Chat migrated!" = "A csevegés átköltöztetve!"; @@ -976,7 +1049,7 @@ "Chat preferences" = "Csevegési beállítások"; /* alert message */ -"Chat preferences were changed." = "A csevegési beállítások megváltoztak."; +"Chat preferences were changed." = "A csevegési beállítások módosultak."; /* No comment provided by engineer. */ "Chat profile" = "Csevegési profil"; @@ -985,14 +1058,23 @@ "Chat theme" = "Csevegés témája"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni!"; +"Chat will be deleted for all members - this cannot be undone!" = "A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni!"; +"Chat will be deleted for you - this cannot be undone!" = "A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; + +/* chat toolbar */ +"Chat with admins" = "Csevegés az adminisztrátorokkal"; + +/* No comment provided by engineer. */ +"Chat with member" = "Csevegés a taggal"; /* No comment provided by engineer. */ "Chats" = "Csevegések"; +/* No comment provided by engineer. */ +"Chats with members" = "Csevegés a tagokkal"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Üzenetek ellenőrzése 20 percenként."; @@ -1015,13 +1097,13 @@ "Choose from library" = "Választás a könyvtárból"; /* No comment provided by engineer. */ -"Chunks deleted" = "Törölt fájltöredékek"; +"Chunks deleted" = "Törölt töredékek"; /* No comment provided by engineer. */ -"Chunks downloaded" = "Letöltött fájltöredékek"; +"Chunks downloaded" = "Letöltött töredékek"; /* No comment provided by engineer. */ -"Chunks uploaded" = "Feltöltött fájltöredékek"; +"Chunks uploaded" = "Feltöltött töredékek"; /* swipe action */ "Clear" = "Kiürítés"; @@ -1030,10 +1112,16 @@ "Clear conversation" = "Üzenetek kiürítése"; /* No comment provided by engineer. */ -"Clear conversation?" = "Üzenetek kiürítése?"; +"Clear conversation?" = "Kiüríti az üzeneteket?"; /* No comment provided by engineer. */ -"Clear private notes?" = "Privát jegyzetek kiürítése?"; +"Clear group?" = "Kiüríti a csoportot?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Csoport kiürítése vagy törlése?"; + +/* No comment provided by engineer. */ +"Clear private notes?" = "Kiüríti a privát jegyzeteket?"; /* No comment provided by engineer. */ "Clear verification" = "Hitelesítés törlése"; @@ -1045,13 +1133,16 @@ "Color mode" = "Színmód"; /* No comment provided by engineer. */ -"colored" = "színes"; +"colored" = "színezett"; + +/* report reason */ +"Community guidelines violation" = "Közösségi irányelvek megsértése"; /* server test step */ -"Compare file" = "Fájl összehasonlítás"; +"Compare file" = "Fájl-összehasonlítás"; /* No comment provided by engineer. */ -"Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása az ismerősökével."; +"Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása a partnerekével."; /* No comment provided by engineer. */ "complete" = "befejezett"; @@ -1060,7 +1151,7 @@ "Completed" = "Elkészült"; /* No comment provided by engineer. */ -"Conditions accepted on: %@." = "Feltételek elfogadva ekkor: %@."; +"Conditions accepted on: %@." = "Feltételek elfogadásának ideje: %@."; /* No comment provided by engineer. */ "Conditions are accepted for the operator(s): **%@**." = "A következő üzemeltető(k) számára elfogadott feltételek: **%@**."; @@ -1071,29 +1162,26 @@ /* No comment provided by engineer. */ "Conditions of use" = "Használati feltételek"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltető számára."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**."; /* No comment provided by engineer. */ -"Conditions will be accepted on: %@." = "A feltételek ekkor lesznek elfogadva: %@."; +"Conditions will be accepted on: %@." = "A feltételek el lesznek fogadva a következő időpontban: %@."; /* No comment provided by engineer. */ -"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltető számára: %@."; +"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára a következő időpontban: %@."; /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-kiszolgálók beállítása"; +/* No comment provided by engineer. */ +"Configure server operators" = "Kiszolgálóüzemeltetők beállítása"; + /* No comment provided by engineer. */ "Confirm" = "Megerősítés"; /* No comment provided by engineer. */ -"Confirm contact deletion?" = "Biztosan törli az ismerőst?"; +"Confirm contact deletion?" = "Biztosan törli a partnert?"; /* No comment provided by engineer. */ "Confirm database upgrades" = "Adatbázis fejlesztésének megerősítése"; @@ -1119,6 +1207,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Feltöltés megerősítése"; +/* token status text */ +"Confirmed" = "Megerősítve"; + /* server test step */ "Connect" = "Kapcsolódás"; @@ -1135,16 +1226,16 @@ "connect to SimpleX Chat developers." = "kapcsolódás a SimpleX Chat fejlesztőkhöz."; /* No comment provided by engineer. */ -"Connect to your friends faster." = "Kapcsolódjon gyorsabban az ismerőseihez."; +"Connect to your friends faster." = "Kapcsolódjon gyorsabban a partnereihez."; /* No comment provided by engineer. */ -"Connect to yourself?" = "Kapcsolódás saját magához?"; +"Connect to yourself?" = "Kapcsolódik saját magához?"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódás saját magához?\nEz az Ön egyszer használható meghívó-hivatkozása!"; +"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódik saját magához?\nEz a saját egyszer használható meghívója!"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódás saját magához?\nEz az Ön SimpleX-címe!"; +"Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódik saját magához?\nEz a saját SimpleX-címe!"; /* No comment provided by engineer. */ "Connect via contact address" = "Kapcsolódás a kapcsolattartási címen keresztül"; @@ -1153,7 +1244,7 @@ "Connect via link" = "Kapcsolódás egy hivatkozáson keresztül"; /* No comment provided by engineer. */ -"Connect via one-time link" = "Kapcsolódás egyszer használható meghívó-hivatkozáson keresztül"; +"Connect via one-time link" = "Kapcsolódás egyszer használható meghívón keresztül"; /* No comment provided by engineer. */ "Connect with %@" = "Kapcsolódás a következővel: %@"; @@ -1189,10 +1280,10 @@ "connecting (announced)" = "kapcsolódás (bejelentve)"; /* No comment provided by engineer. */ -"connecting (introduced)" = "kapcsolódás (bejelentve)"; +"connecting (introduced)" = "kapcsolódás (bemutatkozva)"; /* No comment provided by engineer. */ -"connecting (introduction invitation)" = "kapcsolódás (bemutatkozó-meghívó)"; +"connecting (introduction invitation)" = "kapcsolódás (bemutatkozó meghívó)"; /* call status */ "connecting call" = "kapcsolódási hívás…"; @@ -1204,7 +1295,7 @@ "Connecting server… (error: %@)" = "Kapcsolódás a kiszolgálóhoz… (hiba: %@)"; /* No comment provided by engineer. */ -"Connecting to contact, please wait or check later!" = "Kapcsolódás az ismerőshöz, várjon vagy ellenőrizze később!"; +"Connecting to contact, please wait or check later!" = "Kapcsolódás a partnerhez, várjon vagy ellenőrizze később!"; /* No comment provided by engineer. */ "Connecting to desktop" = "Kapcsolódás a számítógéphez"; @@ -1218,6 +1309,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Kapcsolatok- és kiszolgálók állapotának megjelenítése."; +/* No comment provided by engineer. */ +"Connection blocked" = "A kapcsolat le van tiltva"; + /* No comment provided by engineer. */ "Connection error" = "Kapcsolódási hiba"; @@ -1227,11 +1321,20 @@ /* chat list item title (it should not be shown */ "connection established" = "kapcsolat létrehozva"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "A kiszolgáló üzemeltetője letiltotta a kapcsolatot:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "A kapcsolat nem áll készen."; + /* No comment provided by engineer. */ "Connection notifications" = "Kapcsolódási értesítések"; /* No comment provided by engineer. */ -"Connection request sent!" = "Kapcsolatkérés elküldve!"; +"Connection request sent!" = "Meghívási kérés elküldve!"; + +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "A kapcsolat titkosítása újraegyeztetést igényel."; /* No comment provided by engineer. */ "Connection security" = "Kapcsolatbiztonság"; @@ -1252,46 +1355,58 @@ "Connections" = "Kapcsolatok"; /* profile update event chat item */ -"contact %@ changed to %@" = "%1$@ megváltoztatta a nevét erre: %2$@"; +"contact %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; /* No comment provided by engineer. */ -"Contact allows" = "Ismerős engedélyezi"; +"Contact allows" = "Partner engedélyezi"; /* No comment provided by engineer. */ -"Contact already exists" = "Az ismerős már létezik"; +"Contact already exists" = "A partner már létezik"; /* No comment provided by engineer. */ -"Contact deleted!" = "Ismerős törölve!"; +"contact deleted" = "partner törölve"; /* No comment provided by engineer. */ -"contact has e2e encryption" = "az ismerősnél az e2e titkosítás elérhető"; +"Contact deleted!" = "Partner törölve!"; /* No comment provided by engineer. */ -"contact has no e2e encryption" = "az ismerősnél az e2e titkosítás nem elérhető"; +"contact disabled" = "partner letiltva"; + +/* No comment provided by engineer. */ +"contact has e2e encryption" = "a partner e2e titkosítással rendelkezik"; + +/* No comment provided by engineer. */ +"contact has no e2e encryption" = "a partner nem rendelkezik e2e titkosítással"; /* notification */ -"Contact hidden:" = "Ismerős elrejtve:"; +"Contact hidden:" = "Rejtett név:"; /* notification */ -"Contact is connected" = "Ismerőse kapcsolódott"; +"Contact is connected" = "Partnere kapcsolódott"; /* No comment provided by engineer. */ -"Contact is deleted." = "Törölt ismerős."; +"Contact is deleted." = "Törölt partner."; /* No comment provided by engineer. */ "Contact name" = "Csak név"; /* No comment provided by engineer. */ -"Contact preferences" = "Ismerős beállításai"; +"contact not ready" = "a kapcsolat nem áll készen"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza!"; +"Contact preferences" = "Partnerbeállítások"; /* No comment provided by engineer. */ -"Contacts" = "Ismerősök"; +"Contact will be deleted - this cannot be undone!" = "A partner törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Contacts can mark messages for deletion; you will be able to view them." = "Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat."; +"Contacts" = "Partnerek"; + +/* No comment provided by engineer. */ +"Contacts can mark messages for deletion; you will be able to view them." = "A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat."; + +/* blocking reason */ +"Content violates conditions of use" = "A tartalom sérti a használati feltételeket"; /* No comment provided by engineer. */ "Continue" = "Folytatás"; @@ -1306,22 +1421,22 @@ "Copy error" = "Másolási hiba"; /* No comment provided by engineer. */ -"Core version: v%@" = "Alapverziószám: v%@"; +"Core version: v%@" = "Fő verzió: v%@"; /* No comment provided by engineer. */ "Corner" = "Sarok"; /* No comment provided by engineer. */ -"Correct name to %@?" = "Név javítása erre: %@?"; +"Correct name to %@?" = "Helyesbíti a nevet a következőre: %@?"; /* No comment provided by engineer. */ "Create" = "Létrehozás"; /* No comment provided by engineer. */ -"Create 1-time link" = "Egyszer használható meghívó-hivatkozás létrehozása"; +"Create 1-time link" = "Egyszer használható meghívó létrehozása"; /* No comment provided by engineer. */ -"Create a group using a random profile." = "Csoport létrehozása véletlenszerűen létrehozott profillal."; +"Create a group using a random profile." = "Csoport létrehozása véletlenszerű profillal."; /* server test step */ "Create file" = "Fájl létrehozása"; @@ -1335,6 +1450,9 @@ /* No comment provided by engineer. */ "Create link" = "Hivatkozás létrehozása"; +/* No comment provided by engineer. */ +"Create list" = "Lista létrehozása"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻"; @@ -1342,7 +1460,7 @@ "Create profile" = "Profil létrehozása"; /* server test step */ -"Create queue" = "Sorbaállítás létrehozása"; +"Create queue" = "Sorba állítás létrehozása"; /* No comment provided by engineer. */ "Create secret group" = "Titkos csoport létrehozása"; @@ -1357,10 +1475,10 @@ "Created" = "Létrehozva"; /* No comment provided by engineer. */ -"Created at" = "Létrehozva ekkor:"; +"Created at" = "Létrehozva"; /* copied message info */ -"Created at: %@" = "Létrehozva ekkor: %@"; +"Created at: %@" = "Létrehozva: %@"; /* No comment provided by engineer. */ "Creating archive link" = "Archívum hivatkozás létrehozása"; @@ -1372,7 +1490,7 @@ "creator" = "készítő"; /* No comment provided by engineer. */ -"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül:"; +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; /* No comment provided by engineer. */ "Current Passcode" = "Jelenlegi jelkód"; @@ -1384,16 +1502,16 @@ "Current profile" = "Jelenlegi profil"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Jelenleg a maximális támogatott fájlméret %@."; +"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájl méret: %@."; /* dropdown time picker choice */ -"custom" = "egyedi"; +"custom" = "egyéni"; /* No comment provided by engineer. */ -"Custom time" = "Személyreszabott idő"; +"Custom time" = "Egyéni időköz"; /* No comment provided by engineer. */ -"Customizable message shape." = "Testreszabható üzenetbuborékok."; +"Customizable message shape." = "Személyre szabható üzenetbuborékok."; /* No comment provided by engineer. */ "Customize theme" = "Téma személyre szabása"; @@ -1411,10 +1529,10 @@ "Database encrypted!" = "Adatbázis titkosítva!"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated and stored in the keychain.\n" = "Az adatbázis titkosítási jelmondata frissítve lesz és a kulcstartóban kerül tárolásra.\n"; +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Az adatbázis titkosítási jelmondata frissülni fog és a kulcstartóban lesz tárolva.\n"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated.\n" = "Az datbázis titkosítási jelmondata frissítve lesz.\n"; +"Database encryption passphrase will be updated.\n" = "Az adatbázis titkosítási jelmondata frissítve lesz.\n"; /* No comment provided by engineer. */ "Database error" = "Adatbázishiba"; @@ -1426,13 +1544,13 @@ "Database ID: %d" = "Adatbázis-azonosító: %d"; /* No comment provided by engineer. */ -"Database IDs and Transport isolation option." = "Adatbázis-azonosítók és átvitel-izolációs beállítások."; +"Database IDs and Transport isolation option." = "Adatbázis-azonosítók és átvitelelkülönítési beállítások."; /* No comment provided by engineer. */ -"Database is encrypted using a random passphrase, you can change it." = "Az adatbázis egy véletlenszerű jelmondattal van titkosítva, ami megváltoztatható."; +"Database is encrypted using a random passphrase, you can change it." = "Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet szabadon módosíthat."; /* No comment provided by engineer. */ -"Database is encrypted using a random passphrase. Please change it before exporting." = "Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg."; +"Database is encrypted using a random passphrase. Please change it before exporting." = "Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt módosítsa."; /* No comment provided by engineer. */ "Database passphrase" = "Adatbázis-jelmondat"; @@ -1441,7 +1559,7 @@ "Database passphrase & export" = "Adatbázis-jelmondat és -exportálás"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban mentettől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; /* No comment provided by engineer. */ "Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; @@ -1450,16 +1568,16 @@ "Database upgrade" = "Adatbázis fejlesztése"; /* No comment provided by engineer. */ -"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés ehhez: %@"; +"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de a visszafelé történő átköltöztetés viszont nem lehetséges a következőhöz: %@"; /* No comment provided by engineer. */ -"Database will be encrypted and the passphrase stored in the keychain.\n" = "Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban kerül tárolásra.\n"; +"Database will be encrypted and the passphrase stored in the keychain.\n" = "Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban lesz tárolva.\n"; /* No comment provided by engineer. */ -"Database will be encrypted.\n" = "Az adatbázis titkosításra kerül.\n"; +"Database will be encrypted.\n" = "Az adatbázis titkosítva lesz.\n"; /* No comment provided by engineer. */ -"Database will be migrated when the app restarts" = "Az adatbázis az alkalmazás újraindításakor átköltöztetésre kerül"; +"Database will be migrated when the app restarts" = "Az adatbázis az alkalmazás újraindításakor lesz átköltöztetve"; /* time unit */ "days" = "nap"; @@ -1471,12 +1589,13 @@ "Decentralized" = "Decentralizált"; /* message decrypt error item */ -"Decryption error" = "Titkosítás visszafejtési hiba"; +"Decryption error" = "Titkosításvisszafejtési hiba"; /* No comment provided by engineer. */ "decryption errors" = "visszafejtési hibák"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "alapértelmezett (%@)"; /* No comment provided by engineer. */ @@ -1486,12 +1605,11 @@ "default (yes)" = "alapértelmezett (igen)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Törlés"; /* No comment provided by engineer. */ -"Delete %lld messages of members?" = "Tagok %lld üzenetének törlése?"; +"Delete %lld messages of members?" = "Törli a tagok %lld üzenetét?"; /* No comment provided by engineer. */ "Delete %lld messages?" = "Töröl %lld üzenetet?"; @@ -1500,7 +1618,7 @@ "Delete address" = "Cím törlése"; /* No comment provided by engineer. */ -"Delete address?" = "Cím törlése?"; +"Delete address?" = "Törli a címet?"; /* No comment provided by engineer. */ "Delete after" = "Törlés ennyi idő után"; @@ -1509,28 +1627,34 @@ "Delete all files" = "Az összes fájl törlése"; /* No comment provided by engineer. */ -"Delete and notify contact" = "Törlés, és az ismerős értesítése"; +"Delete and notify contact" = "Törlés, és a partner értesítése"; /* No comment provided by engineer. */ "Delete chat" = "Csevegés törlése"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Csevegési üzenetek törlése a saját eszközéről."; + /* No comment provided by engineer. */ "Delete chat profile" = "Csevegési profil törlése"; /* No comment provided by engineer. */ -"Delete chat profile?" = "Csevegési profil törlése?"; +"Delete chat profile?" = "Törli a csevegési profilt?"; + +/* alert title */ +"Delete chat with member?" = "Törli a taggal való csevegést?"; /* No comment provided by engineer. */ -"Delete chat?" = "Csevegés törlése?"; +"Delete chat?" = "Törli a csevegést?"; /* No comment provided by engineer. */ "Delete connection" = "Kapcsolat törlése"; /* No comment provided by engineer. */ -"Delete contact" = "Ismerős törlése"; +"Delete contact" = "Partner törlése"; /* No comment provided by engineer. */ -"Delete contact?" = "Ismerős törlése?"; +"Delete contact?" = "Törli a partnert?"; /* No comment provided by engineer. */ "Delete database" = "Adatbázis törlése"; @@ -1542,7 +1666,7 @@ "Delete file" = "Fájl törlése"; /* No comment provided by engineer. */ -"Delete files and media?" = "Fájlok és a médiatartalmak törlése?"; +"Delete files and media?" = "Törli a fájl- és a médiatartalmakat?"; /* No comment provided by engineer. */ "Delete files for all chat profiles" = "Fájlok törlése az összes csevegési profilból"; @@ -1557,24 +1681,27 @@ "Delete group" = "Csoport törlése"; /* No comment provided by engineer. */ -"Delete group?" = "Csoport törlése?"; +"Delete group?" = "Törli a csoportot?"; /* No comment provided by engineer. */ "Delete invitation" = "Meghívó törlése"; /* No comment provided by engineer. */ -"Delete link" = "Hivatkozás törlése"; +"Delete link" = "Törlés"; /* No comment provided by engineer. */ -"Delete link?" = "Hivatkozás törlése?"; +"Delete link?" = "Törli a hivatkozást?"; + +/* alert title */ +"Delete list?" = "Törli a listát?"; /* No comment provided by engineer. */ -"Delete member message?" = "Csoporttag üzenetének törlése?"; +"Delete member message?" = "Törli a tag üzenetét?"; /* No comment provided by engineer. */ -"Delete message?" = "Üzenet törlése?"; +"Delete message?" = "Törli az üzenetet?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Üzenetek törlése"; /* No comment provided by engineer. */ @@ -1584,25 +1711,28 @@ "Delete old database" = "Régi adatbázis törlése"; /* No comment provided by engineer. */ -"Delete old database?" = "Régi adatbázis törlése?"; +"Delete old database?" = "Törli a régi adatbázist?"; /* No comment provided by engineer. */ "Delete or moderate up to 200 messages." = "Legfeljebb 200 üzenet egyszerre való törlése, vagy moderálása."; /* No comment provided by engineer. */ -"Delete pending connection?" = "Függőben lévő ismerőskérelem törlése?"; +"Delete pending connection?" = "Törli a függőben lévő meghívót?"; /* No comment provided by engineer. */ "Delete profile" = "Profil törlése"; /* server test step */ -"Delete queue" = "Sorbaállítás törlése"; +"Delete queue" = "Sorba állítás törlése"; + +/* No comment provided by engineer. */ +"Delete report" = "Jelentés törlése"; /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Legfeljebb 20 üzenet egyszerre való törlése."; /* No comment provided by engineer. */ -"Delete user profile?" = "Felhasználói profil törlése?"; +"Delete user profile?" = "Törli a felhasználói profilt?"; /* No comment provided by engineer. */ "Delete without notification" = "Törlés értesítés nélkül"; @@ -1614,13 +1744,13 @@ "Deleted" = "Törölve"; /* No comment provided by engineer. */ -"Deleted at" = "Törölve ekkor:"; +"Deleted at" = "Törölve"; /* copied message info */ -"Deleted at: %@" = "Törölve ekkor: %@"; +"Deleted at: %@" = "Törölve: %@"; /* rcv direct event chat item */ -"deleted contact" = "törölt ismerős"; +"deleted contact" = "törölt partner"; /* rcv group event chat item */ "deleted group" = "törölt csoport"; @@ -1635,7 +1765,7 @@ "Delivery" = "Kézbesítés"; /* No comment provided by engineer. */ -"Delivery receipts are disabled!" = "A kézbesítési jelentések ki vannak kapcsolva!"; +"Delivery receipts are disabled!" = "A kézbesítési jelentések le vannak tiltva!"; /* No comment provided by engineer. */ "Delivery receipts!" = "Kézbesítési jelentések!"; @@ -1647,19 +1777,19 @@ "Desktop address" = "Számítógép címe"; /* No comment provided by engineer. */ -"Desktop app version %@ is not compatible with this app." = "A számítógép-alkalmazás verziója %@ nem kompatibilis ezzel az alkalmazással."; +"Desktop app version %@ is not compatible with this app." = "A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; /* No comment provided by engineer. */ "Desktop devices" = "Számítógépek"; /* No comment provided by engineer. */ -"Destination server address of %@ is incompatible with forwarding server %@ settings." = "A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbító kiszolgáló beállításaival."; +"Destination server address of %@ is incompatible with forwarding server %@ settings." = "A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbítókiszolgáló beállításaival."; /* snd error text */ -"Destination server error: %@" = "Célkiszolgáló hiba: %@"; +"Destination server error: %@" = "Célkiszolgáló-hiba: %@"; /* No comment provided by engineer. */ -"Destination server version of %@ is incompatible with forwarding server %@." = "A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbító kiszolgálóval."; +"Destination server version of %@ is incompatible with forwarding server %@." = "A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbítókiszolgálóval."; /* No comment provided by engineer. */ "Detailed statistics" = "Részletes statisztikák"; @@ -1686,10 +1816,10 @@ "Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "Az eszközön nincs beállítva a képernyőzár. A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be, miután beállította a képernyőzárat az eszközén."; /* No comment provided by engineer. */ -"different migration in the app/database: %@ / %@" = "különböző átköltöztetések az alkalmazásban/adatbázisban: %@ / %@"; +"different migration in the app/database: %@ / %@" = "különböző átköltöztetés az alkalmazásban/adatbázisban: %@ / %@"; /* No comment provided by engineer. */ -"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitel-izoláció."; +"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitelizoláció."; /* connection level description */ "direct" = "közvetlen"; @@ -1701,13 +1831,19 @@ "Direct messages between members are prohibited in this chat." = "A tagok közötti közvetlen üzenetek le vannak tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Direct messages between members are prohibited." = "A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban."; +"Direct messages between members are prohibited." = "A tagok közötti közvetlen üzenetek le vannak tiltva."; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Letiltás (felülírások megtartásával)"; +/* alert title */ +"Disable automatic message deletion?" = "Letiltja az automatikus üzenettörlést?"; + +/* alert button */ +"Disable delete messages" = "Üzenetek törlésének letiltása"; + /* No comment provided by engineer. */ -"Disable for all" = "Letiltás az összes tag számára"; +"Disable for all" = "Letiltás"; /* authentication reason */ "Disable SimpleX Lock" = "SimpleX-zár kikapcsolása"; @@ -1728,22 +1864,22 @@ "Disappearing messages are prohibited in this chat." = "Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Disappearing messages are prohibited." = "Az eltűnő üzenetek küldése le van tiltva ebben a csoportban."; +"Disappearing messages are prohibited." = "Az eltűnő üzenetek küldése le van tiltva."; /* No comment provided by engineer. */ -"Disappears at" = "Eltűnik ekkor:"; +"Disappears at" = "Eltűnik"; /* copied message info */ -"Disappears at: %@" = "Eltűnik ekkor: %@"; +"Disappears at: %@" = "Eltűnik: %@"; /* server test step */ "Disconnect" = "Kapcsolat bontása"; /* No comment provided by engineer. */ -"Disconnect desktop?" = "Számítógép leválasztása?"; +"Disconnect desktop?" = "Leválasztja a számítógépet?"; /* No comment provided by engineer. */ -"Discover and join groups" = "Helyi csoportok felfedezése és csatlakozás"; +"Discover and join groups" = "Csoportok felfedezése és csatlakozás"; /* No comment provided by engineer. */ "Discover via local network" = "Felfedezés helyi hálózaton keresztül"; @@ -1752,19 +1888,22 @@ "Do it later" = "Befejezés később"; /* No comment provided by engineer. */ -"Do not send history to new members." = "Az előzmények ne kerüljenek elküldésre az új tagok számára."; +"Do not send history to new members." = "Az előzmények ne legyenek elküldve az új tagok számára."; /* No comment provided by engineer. */ -"Do NOT send messages directly, even if your or destination server does not support private routing." = "Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Do NOT send messages directly, even if your or destination server does not support private routing." = "NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; /* No comment provided by engineer. */ "Do not use credentials with proxy." = "Ne használja a hitelesítőadatokat proxyval."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "Ne használjon privát útválasztást."; +"Do NOT use private routing." = "NE használjon privát útválasztást."; /* No comment provided by engineer. */ -"Do NOT use SimpleX for emergency calls." = "NE használja a SimpleX-et segélyhívásokhoz."; +"Do NOT use SimpleX for emergency calls." = "NE használja a SimpleXet segélyhívásokhoz."; + +/* No comment provided by engineer. */ +"Documents:" = "Dokumentumok:"; /* No comment provided by engineer. */ "Don't create address" = "Ne hozzon létre címet"; @@ -1773,13 +1912,19 @@ "Don't enable" = "Ne engedélyezze"; /* No comment provided by engineer. */ +"Don't miss important messages." = "Ne maradjon le a fontos üzenetekről."; + +/* alert action */ "Don't show again" = "Ne mutasd újra"; +/* No comment provided by engineer. */ +"Done" = "Kész"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Visszafejlesztés és a csevegés megnyitása"; /* alert button - chat item action */ +chat item action */ "Download" = "Letöltés"; /* No comment provided by engineer. */ @@ -1807,7 +1952,7 @@ "Downloading link details" = "Letöltési hivatkozás részletei"; /* No comment provided by engineer. */ -"Duplicate display name!" = "Duplikált megjelenített név!"; +"Duplicate display name!" = "Duplikált megjelenítendő név!"; /* integrity error chat item */ "duplicate message" = "duplikált üzenet"; @@ -1828,7 +1973,7 @@ "Edit" = "Szerkesztés"; /* No comment provided by engineer. */ -"Edit group profile" = "A csoport profiljának szerkesztése"; +"Edit group profile" = "Csoportprofil szerkesztése"; /* No comment provided by engineer. */ "Enable" = "Engedélyezés"; @@ -1836,14 +1981,14 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Engedélyezés (felülírások megtartásával)"; -/* No comment provided by engineer. */ -"Enable automatic message deletion?" = "Automatikus üzenet törlés engedélyezése?"; +/* alert title */ +"Enable automatic message deletion?" = "Engedélyezi az automatikus üzenettörlést?"; /* No comment provided by engineer. */ "Enable camera access" = "Kamera hozzáférés engedélyezése"; /* No comment provided by engineer. */ -"Enable Flux" = "Flux engedélyezése"; +"Enable Flux in Network & servers settings for better metadata privacy." = "A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében."; /* No comment provided by engineer. */ "Enable for all" = "Engedélyezés az összes tag számára"; @@ -1852,7 +1997,7 @@ "Enable in direct chats (BETA)!" = "Engedélyezés a közvetlen csevegésekben (BÉTA)!"; /* No comment provided by engineer. */ -"Enable instant notifications?" = "Azonnali értesítések engedélyezése?"; +"Enable instant notifications?" = "Engedélyezi az azonnali értesítéseket?"; /* No comment provided by engineer. */ "Enable lock" = "Zárolás engedélyezése"; @@ -1861,13 +2006,13 @@ "Enable notifications" = "Értesítések engedélyezése"; /* No comment provided by engineer. */ -"Enable periodic notifications?" = "Időszakos értesítések engedélyezése?"; +"Enable periodic notifications?" = "Engedélyezi az időszakos értesítéseket?"; /* No comment provided by engineer. */ "Enable self-destruct" = "Önmegsemmisítés engedélyezése"; /* set passcode view */ -"Enable self-destruct passcode" = "Önmegsemmisítő jelkód engedélyezése"; +"Enable self-destruct passcode" = "Önmegsemmisítő-jelkód engedélyezése"; /* authentication reason */ "Enable SimpleX Lock" = "SimpleX-zár bekapcsolása"; @@ -1882,10 +2027,10 @@ "Enabled" = "Engedélyezve"; /* No comment provided by engineer. */ -"Enabled for" = "Számukra engedélyezve:"; +"Enabled for" = "Számukra engedélyezve"; /* enabled status */ -"enabled for contact" = "engedélyezve az ismerős számára"; +"enabled for contact" = "engedélyezve a partner számára"; /* enabled status */ "enabled for you" = "engedélyezve az Ön számára"; @@ -1894,7 +2039,7 @@ "Encrypt" = "Titkosít"; /* No comment provided by engineer. */ -"Encrypt database?" = "Adatbázis titkosítása?"; +"Encrypt database?" = "Titkosítja az adatbázist?"; /* No comment provided by engineer. */ "Encrypt local files" = "Helyi fájlok titkosítása"; @@ -1909,7 +2054,7 @@ "Encrypted message or another event" = "Titkosított üzenet vagy más esemény"; /* notification */ -"Encrypted message: app is stopped" = "Titkosított üzenet: az alkalmazás leállt"; +"Encrypted message: app is stopped" = "Titkosított üzenet: az alkalmazás megállt"; /* notification */ "Encrypted message: database error" = "Titkosított üzenet: adatbázishiba"; @@ -1936,25 +2081,28 @@ "encryption ok" = "titkosítás rendben"; /* chat item text */ -"encryption ok for %@" = "titkosítás rendben vele: %@"; +"encryption ok for %@" = "titkosítás rendben %@ számára"; /* chat item text */ -"encryption re-negotiation allowed" = "titkosítás újraegyeztetés engedélyezve"; +"encryption re-negotiation allowed" = "a titkosítás újraegyeztetése engedélyezve van"; /* chat item text */ -"encryption re-negotiation allowed for %@" = "titkosítás újraegyeztetés engedélyezve vele: %@"; +"encryption re-negotiation allowed for %@" = "a titkosítás újraegyeztetése engedélyezve van %@ számára"; /* message decrypt error item */ -"Encryption re-negotiation error" = "Titkosítás újraegyeztetési hiba"; +"Encryption re-negotiation error" = "Hiba történt a titkosítás újraegyeztetésekor"; /* No comment provided by engineer. */ -"Encryption re-negotiation failed." = "Sikertelen titkosítás-újraegyeztetés."; +"Encryption re-negotiation failed." = "Nem sikerült a titkosítást újraegyeztetni."; /* chat item text */ -"encryption re-negotiation required" = "titkosítás újraegyeztetés szükséges"; +"encryption re-negotiation required" = "a titkosítás újraegyeztetése szükséges"; /* chat item text */ -"encryption re-negotiation required for %@" = "titkosítás újraegyeztetés szükséges %@ számára"; +"encryption re-negotiation required for %@" = "a titkosítás újraegyeztetése szükséges %@ számára"; + +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "A titkosítás újraegyeztetése folyamatban van."; /* No comment provided by engineer. */ "ended" = "befejeződött"; @@ -1966,31 +2114,31 @@ "Enter correct passphrase." = "Adja meg a helyes jelmondatot."; /* No comment provided by engineer. */ -"Enter group name…" = "Csoportnév megadása…"; +"Enter group name…" = "Adja meg a csoport nevét…"; /* No comment provided by engineer. */ -"Enter Passcode" = "Jelkód megadása"; +"Enter Passcode" = "Adja meg a jelkódot"; /* No comment provided by engineer. */ -"Enter passphrase" = "Jelmondat megadása"; +"Enter passphrase" = "Adja meg a jelmondatot"; /* No comment provided by engineer. */ -"Enter passphrase…" = "Jelmondat megadása…"; +"Enter passphrase…" = "Adja meg a jelmondatot…"; /* No comment provided by engineer. */ -"Enter password above to show!" = "Jelszó megadása a megjelenítéshez!"; +"Enter password above to show!" = "Adja meg a jelszót fentebb a megjelenítéshez!"; /* No comment provided by engineer. */ -"Enter server manually" = "Kiszolgáló megadása kézzel"; +"Enter server manually" = "Adja meg a kiszolgálót kézzel"; /* No comment provided by engineer. */ "Enter this device name…" = "Adja meg ennek az eszköznek a nevét…"; /* placeholder */ -"Enter welcome message…" = "Üdvözlőüzenet megadása…"; +"Enter welcome message…" = "Adja meg az üdvözlőüzenetet…"; /* placeholder */ -"Enter welcome message… (optional)" = "Üdvözlőüzenet megadása… (nem kötelező)"; +"Enter welcome message… (optional)" = "Adja meg az üdvözlőüzenetet… (nem kötelező)"; /* No comment provided by engineer. */ "Enter your name…" = "Adjon meg egy nevet…"; @@ -2002,204 +2150,233 @@ "Error" = "Hiba"; /* No comment provided by engineer. */ -"Error aborting address change" = "Hiba a cím megváltoztatásának megszakításakor"; +"Error aborting address change" = "Hiba történt a cím módosításának megszakításakor"; /* alert title */ -"Error accepting conditions" = "Hiba a feltételek elfogadásakor"; +"Error accepting conditions" = "Hiba történt a feltételek elfogadásakor"; /* No comment provided by engineer. */ -"Error accepting contact request" = "Hiba történt a kapcsolatkérés elfogadásakor"; - -/* No comment provided by engineer. */ -"Error adding member(s)" = "Hiba a tag(ok) hozzáadásakor"; +"Error accepting contact request" = "Hiba történt a meghívási kérés elfogadásakor"; /* alert title */ -"Error adding server" = "Hiba a kiszolgáló hozzáadásakor"; +"Error accepting member" = "Hiba a tag befogadásakor"; /* No comment provided by engineer. */ -"Error changing address" = "Hiba a cím megváltoztatásakor"; - -/* No comment provided by engineer. */ -"Error changing connection profile" = "Hiba a kapcsolati profilra való váltáskor"; - -/* No comment provided by engineer. */ -"Error changing role" = "Hiba a szerepkör megváltoztatásakor"; - -/* No comment provided by engineer. */ -"Error changing setting" = "Hiba a beállítás megváltoztatásakor"; - -/* No comment provided by engineer. */ -"Error changing to incognito!" = "Hiba az inkognitóprofilra való váltáskor!"; - -/* No comment provided by engineer. */ -"Error connecting to forwarding server %@. Please try later." = "Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; - -/* No comment provided by engineer. */ -"Error creating address" = "Hiba a cím létrehozásakor"; - -/* No comment provided by engineer. */ -"Error creating group" = "Hiba a csoport létrehozásakor"; - -/* No comment provided by engineer. */ -"Error creating group link" = "Hiba a csoporthivatkozás létrehozásakor"; - -/* No comment provided by engineer. */ -"Error creating member contact" = "Hiba az ismerőssel történő kapcsolat létrehozásában"; - -/* No comment provided by engineer. */ -"Error creating message" = "Hiba az üzenet létrehozásakor"; - -/* No comment provided by engineer. */ -"Error creating profile!" = "Hiba a profil létrehozásakor!"; - -/* No comment provided by engineer. */ -"Error decrypting file" = "Hiba a fájl visszafejtésekor"; - -/* No comment provided by engineer. */ -"Error deleting chat database" = "Hiba a csevegési adatbázis törlésekor"; - -/* No comment provided by engineer. */ -"Error deleting chat!" = "Hiba a csevegés törlésekor!"; - -/* No comment provided by engineer. */ -"Error deleting connection" = "Hiba a kapcsolat törlésekor"; - -/* No comment provided by engineer. */ -"Error deleting database" = "Hiba az adatbázis törlésekor"; - -/* No comment provided by engineer. */ -"Error deleting old database" = "Hiba a régi adatbázis törlésekor"; - -/* No comment provided by engineer. */ -"Error deleting token" = "Hiba a token törlésekor"; - -/* No comment provided by engineer. */ -"Error deleting user profile" = "Hiba a felhasználó-profil törlésekor"; - -/* No comment provided by engineer. */ -"Error downloading the archive" = "Hiba az archívum letöltésekor"; - -/* No comment provided by engineer. */ -"Error enabling delivery receipts!" = "Hiba a kézbesítési jelentések engedélyezésekor!"; - -/* No comment provided by engineer. */ -"Error enabling notifications" = "Hiba az értesítések engedélyezésekor"; - -/* No comment provided by engineer. */ -"Error encrypting database" = "Hiba az adatbázis titkosításakor"; - -/* No comment provided by engineer. */ -"Error exporting chat database" = "Hiba a csevegési adatbázis exportálásakor"; - -/* No comment provided by engineer. */ -"Error exporting theme: %@" = "Hiba a téma exportálásakor: %@"; - -/* No comment provided by engineer. */ -"Error importing chat database" = "Hiba a csevegési adatbázis importálásakor"; - -/* No comment provided by engineer. */ -"Error joining group" = "Hiba a csoporthoz való csatlakozáskor"; +"Error adding member(s)" = "Hiba történt a tag(ok) hozzáadásakor"; /* alert title */ -"Error loading servers" = "Hiba a kiszolgálók betöltésekor"; +"Error adding server" = "Hiba történt a kiszolgáló hozzáadásakor"; /* No comment provided by engineer. */ -"Error migrating settings" = "Hiba a beallítások átköltöztetésekor"; +"Error changing address" = "Hiba történt a cím módosításakor"; /* No comment provided by engineer. */ -"Error opening chat" = "Hiba a csevegés megnyitásakor"; +"Error changing connection profile" = "Hiba történt a kapcsolati profilra való váltáskor"; + +/* No comment provided by engineer. */ +"Error changing role" = "Hiba történt a szerepkör módosításakor"; + +/* No comment provided by engineer. */ +"Error changing setting" = "Hiba történt a beállítás módosításakor"; + +/* No comment provided by engineer. */ +"Error changing to incognito!" = "Hiba történt az inkognitóprofilra való váltáskor!"; + +/* No comment provided by engineer. */ +"Error checking token status" = "Hiba történt a token állapotának ellenőrzésekor"; + +/* No comment provided by engineer. */ +"Error connecting to forwarding server %@. Please try later." = "Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; + +/* No comment provided by engineer. */ +"Error creating address" = "Hiba történt a cím létrehozásakor"; + +/* No comment provided by engineer. */ +"Error creating group" = "Hiba történt a csoport létrehozásakor"; + +/* No comment provided by engineer. */ +"Error creating group link" = "Hiba történt a csoporthivatkozás létrehozásakor"; /* alert title */ -"Error receiving file" = "Hiba a fájl fogadásakor"; +"Error creating list" = "Hiba történt a lista létrehozásakor"; /* No comment provided by engineer. */ -"Error reconnecting server" = "Hiba a kiszolgálóhoz való újrakapcsolódáskor"; +"Error creating member contact" = "Hiba történt a partnerrel történő kapcsolat létrehozásában"; /* No comment provided by engineer. */ -"Error reconnecting servers" = "Hiba a kiszolgálókhoz való újrakapcsolódáskor"; +"Error creating message" = "Hiba történt az üzenet létrehozásakor"; /* No comment provided by engineer. */ -"Error removing member" = "Hiba a tag eltávolításakor"; +"Error creating profile!" = "Hiba történt a profil létrehozásakor!"; /* No comment provided by engineer. */ -"Error resetting statistics" = "Hiba a statisztikák visszaállításakor"; +"Error creating report" = "Hiba történt a jelentés létrehozásakor"; /* No comment provided by engineer. */ -"Error saving group profile" = "Hiba a csoportprofil mentésekor"; +"Error decrypting file" = "Hiba történt a fájl visszafejtésekor"; /* No comment provided by engineer. */ -"Error saving ICE servers" = "Hiba az ICE-kiszolgálók mentésekor"; - -/* No comment provided by engineer. */ -"Error saving passcode" = "Hiba a jelkód mentésekor"; - -/* No comment provided by engineer. */ -"Error saving passphrase to keychain" = "Hiba a jelmondat kulcstartóba történő mentésekor"; +"Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; /* alert title */ -"Error saving servers" = "Hiba a kiszolgálók mentésekor"; +"Error deleting chat with member" = "Hiba a taggal való csevegés törlésekor"; + +/* No comment provided by engineer. */ +"Error deleting chat!" = "Hiba történt a csevegés törlésekor!"; + +/* No comment provided by engineer. */ +"Error deleting connection" = "Hiba történt a kapcsolat törlésekor"; + +/* No comment provided by engineer. */ +"Error deleting database" = "Hiba történt az adatbázis törlésekor"; + +/* No comment provided by engineer. */ +"Error deleting old database" = "Hiba történt a régi adatbázis törlésekor"; + +/* No comment provided by engineer. */ +"Error deleting token" = "Hiba történt a token törlésekor"; + +/* No comment provided by engineer. */ +"Error deleting user profile" = "Hiba történt a felhasználói profil törlésekor"; + +/* No comment provided by engineer. */ +"Error downloading the archive" = "Hiba történt az archívum letöltésekor"; + +/* No comment provided by engineer. */ +"Error enabling delivery receipts!" = "Hiba történt a kézbesítési jelentések engedélyezésekor!"; + +/* No comment provided by engineer. */ +"Error enabling notifications" = "Hiba történt az értesítések engedélyezésekor"; + +/* No comment provided by engineer. */ +"Error encrypting database" = "Hiba történt az adatbázis titkosításakor"; + +/* No comment provided by engineer. */ +"Error exporting chat database" = "Hiba történt a csevegési adatbázis exportálásakor"; + +/* No comment provided by engineer. */ +"Error exporting theme: %@" = "Hiba történt a téma exportálásakor: %@"; + +/* No comment provided by engineer. */ +"Error importing chat database" = "Hiba történt a csevegési adatbázis importálásakor"; + +/* No comment provided by engineer. */ +"Error joining group" = "Hiba történt a csoporthoz való csatlakozáskor"; + +/* alert title */ +"Error loading servers" = "Hiba történt a kiszolgálók betöltésekor"; + +/* No comment provided by engineer. */ +"Error migrating settings" = "Hiba történt a beállítások átköltöztetésekor"; + +/* No comment provided by engineer. */ +"Error opening chat" = "Hiba történt a csevegés megnyitásakor"; + +/* alert title */ +"Error receiving file" = "Hiba történt a fájl fogadásakor"; + +/* No comment provided by engineer. */ +"Error reconnecting server" = "Hiba történt a kiszolgálóhoz való újrakapcsolódáskor"; + +/* No comment provided by engineer. */ +"Error reconnecting servers" = "Hiba történt a kiszolgálókhoz való újrakapcsolódáskor"; + +/* alert title */ +"Error registering for notifications" = "Hiba történt az értesítések regisztrálásakor"; + +/* alert title */ +"Error removing member" = "Hiba történt a tag eltávolításakor"; + +/* alert title */ +"Error reordering lists" = "Hiba történt a listák újrarendezésekor"; + +/* No comment provided by engineer. */ +"Error resetting statistics" = "Hiba történt a statisztikák visszaállításakor"; + +/* alert title */ +"Error saving chat list" = "Hiba történt a csevegési lista mentésekor"; + +/* No comment provided by engineer. */ +"Error saving group profile" = "Hiba történt a csoportprofil mentésekor"; + +/* No comment provided by engineer. */ +"Error saving ICE servers" = "Hiba történt az ICE-kiszolgálók mentésekor"; + +/* No comment provided by engineer. */ +"Error saving passcode" = "Hiba történt a jelkód mentésekor"; + +/* No comment provided by engineer. */ +"Error saving passphrase to keychain" = "Hiba történt a jelmondat kulcstartóba történő mentésekor"; + +/* alert title */ +"Error saving servers" = "Hiba történt a kiszolgálók mentésekor"; /* when migrating */ -"Error saving settings" = "Hiba a beállítások mentésekor"; +"Error saving settings" = "Hiba történt a beállítások mentésekor"; /* No comment provided by engineer. */ -"Error saving user password" = "Hiba a felhasználói jelszó mentésekor"; +"Error saving user password" = "Hiba történt a felhasználó jelszavának mentésekor"; /* No comment provided by engineer. */ -"Error scanning code: %@" = "Hiba a kód beolvasásakor: %@"; +"Error scanning code: %@" = "Hiba történt a kód beolvasásakor: %@"; /* No comment provided by engineer. */ -"Error sending email" = "Hiba az e-mail küldésekor"; +"Error sending email" = "Hiba történt az e-mail elküldésekor"; /* No comment provided by engineer. */ "Error sending member contact invitation" = "Hiba történt a tag kapcsolatfelvételi meghívójának elküldésekor"; /* No comment provided by engineer. */ -"Error sending message" = "Hiba az üzenet küldésekor"; +"Error sending message" = "Hiba történt az üzenet elküldésekor"; /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Hiba történt a kézbesítési jelentések beállításakor!"; /* No comment provided by engineer. */ -"Error starting chat" = "Hiba a csevegés elindításakor"; +"Error starting chat" = "Hiba történt a csevegés elindításakor"; /* No comment provided by engineer. */ -"Error stopping chat" = "Hiba a csevegés megállításakor"; +"Error stopping chat" = "Hiba történt a csevegés megállításakor"; /* No comment provided by engineer. */ -"Error switching profile" = "Hiba a profilváltáskor"; +"Error switching profile" = "Hiba történt a profilváltáskor"; /* alertTitle */ -"Error switching profile!" = "Hiba a profilváltásakor!"; +"Error switching profile!" = "Hiba történt a profilváltáskor!"; /* No comment provided by engineer. */ -"Error synchronizing connection" = "Hiba a kapcsolat szinkronizálásakor"; +"Error synchronizing connection" = "Hiba történt a kapcsolat szinkronizálásakor"; /* No comment provided by engineer. */ -"Error updating group link" = "Hiba a csoporthivatkozás frissítésekor"; +"Error testing server connection" = "Hiba történt a kiszolgáló kapcsolatának tesztelésekor"; /* No comment provided by engineer. */ -"Error updating message" = "Hiba az üzenet frissítésekor"; +"Error updating group link" = "Hiba történt a csoporthivatkozás frissítésekor"; + +/* No comment provided by engineer. */ +"Error updating message" = "Hiba történt az üzenet frissítésekor"; /* alert title */ -"Error updating server" = "Hiba a kiszolgáló frissítésekor"; +"Error updating server" = "Hiba történt a kiszolgáló frissítésekor"; /* No comment provided by engineer. */ "Error updating settings" = "Hiba történt a beállítások frissítésekor"; /* No comment provided by engineer. */ -"Error updating user privacy" = "Hiba a felhasználói adatvédelem frissítésekor"; +"Error updating user privacy" = "Hiba történt a felhasználói adatvédelem frissítésekor"; /* No comment provided by engineer. */ -"Error uploading the archive" = "Hiba az archívum feltöltésekor"; +"Error uploading the archive" = "Hiba történt az archívum feltöltésekor"; /* No comment provided by engineer. */ -"Error verifying passphrase:" = "Hiba a jelmondat hitelesítésekor:"; +"Error verifying passphrase:" = "Hiba történt a jelmondat hitelesítésekor:"; /* No comment provided by engineer. */ "Error: " = "Hiba: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Hiba: %@"; /* No comment provided by engineer. */ @@ -2217,9 +2394,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Akkor is, ha le van tiltva a beszélgetésben."; -/* No comment provided by engineer. */ -"event happened" = "esemény történt"; - /* No comment provided by engineer. */ "Exit without saving" = "Kilépés mentés nélkül"; @@ -2229,6 +2403,9 @@ /* No comment provided by engineer. */ "expired" = "lejárt"; +/* token status text */ +"Expired" = "Lejárt"; + /* No comment provided by engineer. */ "Export database" = "Adatbázis exportálása"; @@ -2253,29 +2430,41 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Gyors és nem kell várni, amíg a feladó online lesz!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Gyorsabb csoporttörlés."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Gyorsabb üzenetküldés."; + /* swipe action */ "Favorite" = "Kedvenc"; /* No comment provided by engineer. */ +"Favorites" = "Kedvencek"; + +/* file error alert title */ "File error" = "Fájlhiba"; /* alert message */ "File errors:\n%@" = "Fájlhiba:\n%@"; /* file error text */ -"File not found - most likely file was deleted or cancelled." = "A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták."; +"File is blocked by server operator:\n%@." = "A kiszolgáló üzemeltetője letiltotta a fájlt:\n%@."; /* file error text */ -"File server error: %@" = "Fájlkiszolgáló hiba: %@"; +"File not found - most likely file was deleted or cancelled." = "A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták."; + +/* file error text */ +"File server error: %@" = "Fájlkiszolgáló-hiba: %@"; /* No comment provided by engineer. */ -"File status" = "Fájlállapot"; +"File status" = "Fájl állapota"; /* copied message info */ -"File status: %@" = "Fájlállapot: %@"; +"File status: %@" = "Fájl állapota: %@"; /* No comment provided by engineer. */ "File will be deleted from servers." = "A fájl törölve lesz a kiszolgálókról."; @@ -2299,7 +2488,7 @@ "Files and media" = "Fájlok és médiatartalmak"; /* No comment provided by engineer. */ -"Files and media are prohibited." = "A fájlok- és a médiatartalmak le vannak tiltva ebben a csoportban."; +"Files and media are prohibited." = "A fájlok- és a médiatartalmak küldése le van tiltva."; /* No comment provided by engineer. */ "Files and media not allowed" = "A fájlok- és médiatartalmak nincsenek engedélyezve"; @@ -2335,13 +2524,13 @@ "Fix encryption after restoring backups." = "Titkosítás javítása az adatmentések helyreállítása után."; /* No comment provided by engineer. */ -"Fix not supported by contact" = "Ismerős általi javítás nem támogatott"; +"Fix not supported by contact" = "Partner általi javítás nem támogatott"; /* No comment provided by engineer. */ "Fix not supported by group member" = "Csoporttag általi javítás nem támogatott"; /* No comment provided by engineer. */ -"for better metadata privacy." = "a metaadatok jobb védelme érdekében."; +"For all moderators" = "Az összes moderátor számára"; /* servers error */ "For chat profile %@:" = "A(z) %@ nevű csevegési profilhoz:"; @@ -2350,7 +2539,10 @@ "For console" = "Konzolhoz"; /* No comment provided by engineer. */ -"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni."; +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Például, ha a partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni."; + +/* No comment provided by engineer. */ +"For me" = "Csak magamnak"; /* No comment provided by engineer. */ "For private routing" = "A privát útválasztáshoz"; @@ -2362,7 +2554,7 @@ "Forward" = "Továbbítás"; /* alert title */ -"Forward %d message(s)?" = "%d üzenet továbbítása?"; +"Forward %d message(s)?" = "Továbbít %d üzenetet?"; /* No comment provided by engineer. */ "Forward and save messages" = "Üzenetek továbbítása és mentése"; @@ -2371,7 +2563,7 @@ "Forward messages" = "Üzenetek továbbítása"; /* alert message */ -"Forward messages without files?" = "Üzenetek továbbítása fájlok nélkül?"; +"Forward messages without files?" = "Továbbítja az üzeneteket fájlok nélkül?"; /* No comment provided by engineer. */ "Forward up to 20 messages at once." = "Legfeljebb 20 üzenet egyszerre való továbbítása."; @@ -2383,25 +2575,25 @@ "Forwarded" = "Továbbított"; /* No comment provided by engineer. */ -"Forwarded from" = "Továbbítva innen:"; +"Forwarded from" = "Továbbítva innen"; /* No comment provided by engineer. */ "Forwarding %lld messages" = "%lld üzenet továbbítása"; /* No comment provided by engineer. */ -"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %@ továbbító-kiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később."; +"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %@ továbbítókiszolgáló nem tudott kapcsolódni a(z) %@ célkiszolgálóhoz. Próbálja meg később."; /* No comment provided by engineer. */ -"Forwarding server address is incompatible with network settings: %@." = "A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@."; +"Forwarding server address is incompatible with network settings: %@." = "A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@."; /* No comment provided by engineer. */ -"Forwarding server version is incompatible with network settings: %@." = "A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@."; +"Forwarding server version is incompatible with network settings: %@." = "A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@."; /* snd error text */ -"Forwarding server: %@\nDestination server error: %@" = "Továbbító-kiszolgáló: %1$@\nCélkiszolgáló hiba:%2$@"; +"Forwarding server: %@\nDestination server error: %@" = "Továbbítókiszolgáló: %1$@\nCélkiszolgáló-hiba: %2$@"; /* snd error text */ -"Forwarding server: %@\nError: %@" = "Továbbító-kiszolgáló: %1$@\nHiba: %2$@"; +"Forwarding server: %@\nError: %@" = "Továbbítókiszolgáló: %1$@\nHiba: %2$@"; /* No comment provided by engineer. */ "Found desktop" = "Megtalált számítógép"; @@ -2416,14 +2608,17 @@ "Full name (optional)" = "Teljes név (nem kötelező)"; /* No comment provided by engineer. */ -"Fully decentralized – visible only to members." = "Teljesen decentralizált - csak a tagok számára látható."; +"Fully decentralized – visible only to members." = "Teljesen decentralizált – csak a tagok számára látható."; /* No comment provided by engineer. */ -"Fully re-implemented - work in background!" = "Teljesen újra implementálva - háttérben történő működés!"; +"Fully re-implemented - work in background!" = "Teljesen újra implementálva – háttérben történő működés!"; /* No comment provided by engineer. */ "Further reduced battery usage" = "Tovább csökkentett akkumulátor-használat"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Kapjon értesítést, ha megemlítik."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF-ek és matricák"; @@ -2449,7 +2644,7 @@ "Group display name" = "A csoport megjelenített neve"; /* No comment provided by engineer. */ -"Group full name (optional)" = "Csoport teljes neve (nem kötelező)"; +"Group full name (optional)" = "A csoport teljes neve (nem kötelező)"; /* No comment provided by engineer. */ "Group image" = "Csoport profilképe"; @@ -2463,6 +2658,9 @@ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "A csoportmeghívó már nem érvényes, a küldője eltávolította."; +/* No comment provided by engineer. */ +"group is deleted" = "csoport törölve"; + /* No comment provided by engineer. */ "Group link" = "Csoporthivatkozás"; @@ -2473,7 +2671,7 @@ "Group message:" = "Csoport üzenet:"; /* No comment provided by engineer. */ -"Group moderation" = "Csoport moderáció"; +"Group moderation" = "Csoport moderálása"; /* No comment provided by engineer. */ "Group preferences" = "Csoportbeállítások"; @@ -2491,14 +2689,20 @@ "Group welcome message" = "A csoport üdvözlőüzenete"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza!"; +"Group will be deleted for all members - this cannot be undone!" = "A csoport törölve lesz az összes tag számára – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Group will be deleted for you - this cannot be undone!" = "A csoport törlésre kerül az Ön számára - ez a művelet nem vonható vissza!"; +"Group will be deleted for you - this cannot be undone!" = "A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; + +/* No comment provided by engineer. */ +"Groups" = "Csoportok"; /* No comment provided by engineer. */ "Help" = "Súgó"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Segítsen az adminisztrátoroknak a csoportjaik moderálásában."; + /* No comment provided by engineer. */ "Hidden" = "Se név, se üzenet"; @@ -2518,13 +2722,13 @@ "Hide profile" = "Profil elrejtése"; /* No comment provided by engineer. */ -"Hide:" = "Elrejtés:"; +"Hide:" = "Elrejtve:"; /* No comment provided by engineer. */ "History" = "Előzmények"; /* No comment provided by engineer. */ -"History is not sent to new members." = "Az előzmények nem kerülnek elküldésre az új tagok számára."; +"History is not sent to new members." = "Az előzmények nem lesznek elküldve az új tagok számára."; /* time unit */ "hours" = "óra"; @@ -2535,6 +2739,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Hogyan segíti az adatvédelmet"; +/* alert button */ +"How it works" = "Hogyan működik"; + /* No comment provided by engineer. */ "How SimpleX works" = "Hogyan működik a SimpleX"; @@ -2542,10 +2749,10 @@ "How to" = "Hogyan"; /* No comment provided by engineer. */ -"How to use it" = "Hogyan használja"; +"How to use it" = "Használati útmutató"; /* No comment provided by engineer. */ -"How to use your servers" = "Saját kiszolgálók használata"; +"How to use your servers" = "Hogyan használja a saját kiszolgálóit"; /* No comment provided by engineer. */ "Hungarian interface" = "Magyar kezelőfelület"; @@ -2557,13 +2764,13 @@ "If you can't meet in person, show QR code in a video call, or share the link." = "Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást."; /* No comment provided by engineer. */ -"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül!"; +"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva!"; /* No comment provided by engineer. */ -"If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot:"; +"If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot:"; /* No comment provided by engineer. */ -"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor felajánlásra kerül az adatbázis átköltöztetése)."; +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; /* No comment provided by engineer. */ "Ignore" = "Mellőzés"; @@ -2578,13 +2785,13 @@ "Immediately" = "Azonnal"; /* No comment provided by engineer. */ -"Immune to spam" = "Spam és visszaélések elleni védelem"; +"Immune to spam" = "Védett a kéretlen tartalommal szemben"; /* No comment provided by engineer. */ "Import" = "Importálás"; /* No comment provided by engineer. */ -"Import chat database?" = "Csevegési adatbázis importálása?"; +"Import chat database?" = "Importálja a csevegési adatbázist?"; /* No comment provided by engineer. */ "Import database" = "Adatbázis importálása"; @@ -2614,7 +2821,7 @@ "In order to continue, chat should be stopped." = "A folytatáshoz a csevegést meg kell szakítani."; /* No comment provided by engineer. */ -"In reply to" = "Válasz neki"; +"In reply to" = "Válaszul erre"; /* No comment provided by engineer. */ "In-call sounds" = "Bejövő hívás csengőhangja"; @@ -2622,6 +2829,12 @@ /* No comment provided by engineer. */ "inactive" = "inaktív"; +/* report reason */ +"Inappropriate content" = "Kifogásolt tartalom"; + +/* report reason */ +"Inappropriate profile" = "Kifogásolt profil"; + /* No comment provided by engineer. */ "Incognito" = "Inkognitó"; @@ -2632,7 +2845,7 @@ "Incognito mode" = "Inkognitómód"; /* No comment provided by engineer. */ -"Incognito mode protects your privacy by using a new random profile for each contact." = "Az inkognitómód védi személyes adatait azáltal, hogy az összes ismerőséhez új, véletlenszerű profilt használ."; +"Incognito mode protects your privacy by using a new random profile for each contact." = "Az inkognitómód úgy védi a személyes adatait, hogy az összes partneréhez új, véletlenszerű profilt használ."; /* chat list item description */ "incognito via contact address link" = "inkognitó a kapcsolattartási címhivatkozáson keresztül"; @@ -2641,7 +2854,7 @@ "incognito via group link" = "inkognitó a csoporthivatkozáson keresztül"; /* chat list item description */ -"incognito via one-time link" = "inkognitó egy egyszer használható meghívó-hivatkozáson keresztül"; +"incognito via one-time link" = "inkognitó egy egyszer használható meghívón keresztül"; /* notification */ "Incoming audio call" = "Bejövő hanghívás"; @@ -2677,10 +2890,10 @@ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "A [SimpleX Chat terminálhoz] telepítése (https://github.com/simplex-chat/simplex-chat)"; /* No comment provided by engineer. */ -"Instant" = "Azonnal"; +"Instant" = "Azonnali"; /* No comment provided by engineer. */ -"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések elrejtésre kerülnek!\n"; +"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések el lesznek rejtve!\n"; /* No comment provided by engineer. */ "Interface" = "Kezelőfelület"; @@ -2688,6 +2901,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Kezelőfelület színei"; +/* token status text */ +"Invalid" = "Érvénytelen"; + +/* token status text */ +"Invalid (bad token)" = "Érvénytelen (hibás token)"; + +/* token status text */ +"Invalid (expired)" = "Érvénytelen (lejárt)"; + +/* token status text */ +"Invalid (unregistered)" = "Érvénytelen (nincs regisztrálva)"; + +/* token status text */ +"Invalid (wrong topic)" = "Érvénytelen (rossz topic)"; + /* invalid chat data */ "invalid chat" = "érvénytelen csevegés"; @@ -2752,16 +2980,16 @@ "invited %@" = "meghívta őt: %@"; /* chat list item title */ -"invited to connect" = "meghívta, hogy csatlakozzon"; +"invited to connect" = "Függőben lévő meghívó"; /* rcv group event chat item */ -"invited via your group link" = "meghíva az Ön csoporthivatkozásán keresztül"; +"invited via your group link" = "meghíva a saját csoporthivatkozásán keresztül"; /* No comment provided by engineer. */ -"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó az alkalmazás újraindítása, vagy a jelmondat módosítása után a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását."; /* No comment provided by engineer. */ "IP address" = "IP-cím"; @@ -2773,16 +3001,16 @@ "Irreversible message deletion is prohibited in this chat." = "Az üzenetek végleges törlése le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Irreversible message deletion is prohibited." = "Az üzenetek végleges törlése le van tiltva ebben a csoportban."; +"Irreversible message deletion is prohibited." = "Az üzenetek végleges törlése le van tiltva."; /* No comment provided by engineer. */ -"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Lehetővé teszi, hogy egyetlen csevegőprofilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük."; +"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Lehetővé teszi, hogy egyetlen csevegési profilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük."; /* No comment provided by engineer. */ -"It can happen when you or your connection used the old database backup." = "Ez akkor fordulhat elő, ha Ön vagy az ismerőse régi adatbázis biztonsági mentést használt."; +"It can happen when you or your connection used the old database backup." = "Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta."; /* No comment provided by engineer. */ -"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Az üzenet visszafejtése sikertelen volt, mert vagy az ismerőse régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült."; +"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere egy régi adatbázis biztonsági mentését használta.\n3. A kapcsolat sérült."; /* No comment provided by engineer. */ "It protects your IP address and connections." = "Védi az IP-címét és a kapcsolatait."; @@ -2803,7 +3031,7 @@ "Join" = "Csatlakozás"; /* No comment provided by engineer. */ -"join as %@" = "csatlakozás mint: %@"; +"join as %@" = "csatlakozás mint %@"; /* No comment provided by engineer. */ "Join group" = "Csatlakozás csoporthoz"; @@ -2821,13 +3049,13 @@ "Join with current profile" = "Csatlakozás a jelenlegi profillal"; /* No comment provided by engineer. */ -"Join your group?\nThis is your link for group %@!" = "Csatlakozik a csoportjához?\nEz az Ön hivatkozása a(z) %@ nevű csoporthoz!"; +"Join your group?\nThis is your link for group %@!" = "Csatlakozik a csoportjához?\nEz a saját hivatkozása a(z) %@ nevű csoporthoz!"; /* No comment provided by engineer. */ "Joining group" = "Csatlakozás a csoporthoz"; /* alert action */ -"Keep" = "Megtart"; +"Keep" = "Megtartás"; /* No comment provided by engineer. */ "Keep conversation" = "Beszélgetés megtartása"; @@ -2836,7 +3064,7 @@ "Keep the app open to use it from desktop" = "A számítógépről való használathoz tartsd nyitva az alkalmazást"; /* alert title */ -"Keep unused invitation?" = "Fel nem használt meghívó megtartása?"; +"Keep unused invitation?" = "Megtartja a fel nem használt meghívót?"; /* No comment provided by engineer. */ "Keep your connections" = "Kapcsolatok megtartása"; @@ -2860,13 +3088,13 @@ "Leave chat" = "Csevegés elhagyása"; /* No comment provided by engineer. */ -"Leave chat?" = "Csevegés elhagyása?"; +"Leave chat?" = "Elhagyja a csevegést?"; /* No comment provided by engineer. */ "Leave group" = "Csoport elhagyása"; /* No comment provided by engineer. */ -"Leave group?" = "Csoport elhagyása?"; +"Leave group?" = "Elhagyja a csoportot?"; /* rcv group event chat item */ "left" = "elhagyta a csoportot"; @@ -2889,6 +3117,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Társított számítógépek"; +/* swipe action */ +"List" = "Lista"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Az összes lista nevének és emodzsijának különbözőnek kell lennie."; + +/* No comment provided by engineer. */ +"List name..." = "Lista neve…"; + /* No comment provided by engineer. */ "LIVE" = "ÉLŐ"; @@ -2917,7 +3154,7 @@ "Make profile private!" = "Tegye priváttá a profilját!"; /* No comment provided by engineer. */ -"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva."; +"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva."; /* No comment provided by engineer. */ "Mark deleted for everyone" = "Jelölje meg az összes tag számára töröltként"; @@ -2938,7 +3175,7 @@ "Max 30 seconds, received instantly." = "Max. 30 másodperc, azonnal érkezett."; /* No comment provided by engineer. */ -"Media & file servers" = "Média és fájlkiszolgálók"; +"Media & file servers" = "Média- és fájlkiszolgálók"; /* blur media */ "Medium" = "Közepes"; @@ -2950,49 +3187,67 @@ "Member" = "Tag"; /* profile update event chat item */ -"member %@ changed to %@" = "%1$@ megváltoztatta a nevét erre: %2$@"; +"member %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; + +/* No comment provided by engineer. */ +"Member admission" = "Tagbefogadás"; /* rcv group event chat item */ "member connected" = "kapcsolódott"; +/* No comment provided by engineer. */ +"member has old version" = "a tag régi verziót használ"; + /* item status text */ "Member inactive" = "Inaktív tag"; -/* No comment provided by engineer. */ -"Member role will be changed to \"%@\". All chat members will be notified." = "A tag szerepeköre meg fog változni a következőre: \"%@\". A csevegés tagjai értesítést fognak kapni."; +/* chat feature */ +"Member reports" = "Tagok jelentései"; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre meg fog változni erre: „%@”. A csoportban az összes tag értesítve lesz."; +"Member role will be changed to \"%@\". All chat members will be notified." = "A tag szerepköre a következőre fog módosulni: „%@”. A csevegés összes tagja értesítést fog kapni."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre meg fog változni erre: „%@”. A tag új meghívást fog kapni."; +"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre a következőre fog módosulni: „%@”. A csoport az összes tagja értesítést fog kapni."; /* No comment provided by engineer. */ -"Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni!"; +"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre a következőre fog módosulni: „%@”. A tag új meghívást fog kapni."; /* No comment provided by engineer. */ -"Member will be removed from group - this cannot be undone!" = "A tag eltávolítása a csoportból - ez a művelet nem vonható vissza!"; +"Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Members can add message reactions." = "Csoporttagok üzenetreakciókat adhatnak hozzá."; +"Member will be removed from group - this cannot be undone!" = "A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza!"; + +/* alert message */ +"Member will join the group, accept member?" = "A tag csatlakozni akar a csoporthoz, befogadja a tagot?"; /* No comment provided by engineer. */ -"Members can irreversibly delete sent messages. (24 hours)" = "A csoport tagjai véglegesen törölhetik az elküldött üzeneteiket. (24 óra)"; +"Members can add message reactions." = "A tagok reakciókat adhatnak hozzá az üzenetekhez."; /* No comment provided by engineer. */ -"Members can send direct messages." = "A csoport tagjai küldhetnek egymásnak közvetlen üzeneteket."; +"Members can irreversibly delete sent messages. (24 hours)" = "A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra)"; /* No comment provided by engineer. */ -"Members can send disappearing messages." = "A csoport tagjai küldhetnek eltűnő üzeneteket."; +"Members can report messsages to moderators." = "A tagok jelenthetik az üzeneteket a moderátorok felé."; /* No comment provided by engineer. */ -"Members can send files and media." = "A csoport tagjai küldhetnek fájlokat és médiatartalmakat."; +"Members can send direct messages." = "A tagok küldhetnek egymásnak közvetlen üzeneteket."; /* No comment provided by engineer. */ -"Members can send SimpleX links." = "A csoport tagjai küldhetnek SimpleX-hivatkozásokat."; +"Members can send disappearing messages." = "A tagok küldhetnek eltűnő üzeneteket."; /* No comment provided by engineer. */ -"Members can send voice messages." = "A csoport tagjai küldhetnek hangüzeneteket."; +"Members can send files and media." = "A tagok küldhetnek fájlokat és médiatartalmakat."; + +/* No comment provided by engineer. */ +"Members can send SimpleX links." = "A tagok küldhetnek SimpleX-hivatkozásokat."; + +/* No comment provided by engineer. */ +"Members can send voice messages." = "A tagok küldhetnek hangüzeneteket."; + +/* No comment provided by engineer. */ +"Mention members 👋" = "Tagok említése 👋"; /* No comment provided by engineer. */ "Menus" = "Menük"; @@ -3019,16 +3274,16 @@ "Message may be delivered later if member becomes active." = "Az üzenet később is kézbesíthető, ha a tag aktívvá válik."; /* No comment provided by engineer. */ -"Message queue info" = "Üzenet-sorbaállítási információ"; +"Message queue info" = "Üzenetsorbaállítási információ"; /* chat feature */ "Message reactions" = "Üzenetreakciók"; /* No comment provided by engineer. */ -"Message reactions are prohibited in this chat." = "Az üzenetreakciók küldése le van tiltva ebben a csevegésben."; +"Message reactions are prohibited in this chat." = "A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Message reactions are prohibited." = "Az üzenetreakciók küldése le van tiltva ebben a csoportban."; +"Message reactions are prohibited." = "A reakciók hozzáadása az üzenetekhez le van tiltva."; /* notification */ "message received" = "üzenet érkezett"; @@ -3040,16 +3295,16 @@ "Message servers" = "Üzenetkiszolgálók"; /* No comment provided by engineer. */ -"Message shape" = "Üzenetbuborék formája"; +"Message shape" = "Üzenetbuborék alakja"; /* No comment provided by engineer. */ "Message source remains private." = "Az üzenet forrása titokban marad."; /* No comment provided by engineer. */ -"Message status" = "Üzenetállapot"; +"Message status" = "Üzenet állapota"; /* copied message info */ -"Message status: %@" = "Üzenetállapot: %@"; +"Message status: %@" = "Üzenet állapota: %@"; /* No comment provided by engineer. */ "Message text" = "Név és üzenet"; @@ -3064,7 +3319,10 @@ "Messages & files" = "Üzenetek és fájlok"; /* No comment provided by engineer. */ -"Messages from %@ will be shown!" = "A(z) %@ által írt üzenetek megjelennek!"; +"Messages from %@ will be shown!" = "%@ összes üzenete meg fog jelenni!"; + +/* alert message */ +"Messages in this chat will never be deleted." = "Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve."; /* No comment provided by engineer. */ "Messages received" = "Fogadott üzenetek"; @@ -3073,13 +3331,13 @@ "Messages sent" = "Elküldött üzenetek"; /* alert message */ -"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kiválasztotta őket."; +"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kijelölte őket."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzeneteket, fájlokat és hívásokat **végpontok közötti titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi."; +"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzeneteket, fájlokat és hívásokat **végpontok közötti kvantumrezisztens titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi."; +"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; /* No comment provided by engineer. */ "Migrate device" = "Eszköz átköltöztetése"; @@ -3106,7 +3364,7 @@ "Migration complete" = "Átköltöztetés befejezve"; /* No comment provided by engineer. */ -"Migration error:" = "Átköltöztetés hiba:"; +"Migration error:" = "Átköltöztetési hiba:"; /* No comment provided by engineer. */ "Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; @@ -3130,17 +3388,23 @@ "moderated" = "moderált"; /* No comment provided by engineer. */ -"Moderated at" = "Moderálva ekkor:"; +"Moderated at" = "Moderálva"; /* copied message info */ -"Moderated at: %@" = "Moderálva ekkor: %@"; +"Moderated at: %@" = "Moderálva: %@"; /* marked deleted chat item preview text */ "moderated by %@" = "moderálva lett %@ által"; +/* member role */ +"moderator" = "moderátor"; + /* time unit */ "months" = "hónap"; +/* swipe action */ +"More" = "Továbbiak"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Hamarosan további fejlesztések érkeznek!"; @@ -3151,17 +3415,17 @@ "More reliable notifications" = "Megbízhatóbb értesítések"; /* item status description */ -"Most likely this connection is deleted." = "Valószínűleg ez a kapcsolat törlésre került."; +"Most likely this connection is deleted." = "Valószínűleg ez a kapcsolat törölve lett."; /* No comment provided by engineer. */ -"Multiple chat profiles" = "Több csevegőprofil"; +"Multiple chat profiles" = "Több csevegési profil"; -/* No comment provided by engineer. */ -"mute" = "némítás"; - -/* swipe action */ +/* notification label action */ "Mute" = "Némítás"; +/* notification label action */ +"Mute all" = "Összes némítása"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Némítás, ha inaktív!"; @@ -3172,19 +3436,19 @@ "Network & servers" = "Hálózat és kiszolgálók"; /* No comment provided by engineer. */ -"Network connection" = "Internetkapcsolat"; +"Network connection" = "Hálózati kapcsolat"; /* No comment provided by engineer. */ "Network decentralization" = "Hálózati decentralizáció"; /* snd error text */ -"Network issues - message expired after many attempts to send it." = "Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt."; +"Network issues - message expired after many attempts to send it." = "Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt."; /* No comment provided by engineer. */ "Network management" = "Hálózatkezelés"; /* No comment provided by engineer. */ -"Network operator" = "Hálózati üzemeltető"; +"Network operator" = "Hálózatüzemeltető"; /* No comment provided by engineer. */ "Network settings" = "Hálózati beállítások"; @@ -3192,17 +3456,20 @@ /* No comment provided by engineer. */ "Network status" = "Hálózat állapota"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "soha"; +/* token status text */ +"New" = "Új"; + /* No comment provided by engineer. */ -"New chat" = "Új beszélgetés"; +"New chat" = "Új csevegés"; /* No comment provided by engineer. */ "New chat experience 🎉" = "Új csevegési élmény 🎉"; /* notification */ -"New contact request" = "Új kapcsolatkérés"; +"New contact request" = "Új meghívási kérés"; /* notification */ "New contact:" = "Új kapcsolat:"; @@ -3211,7 +3478,7 @@ "New desktop app!" = "Új számítógép-alkalmazás!"; /* No comment provided by engineer. */ -"New display name" = "Új megjelenítési név"; +"New display name" = "Új megjelenítendő név"; /* notification */ "New events" = "Új események"; @@ -3225,6 +3492,9 @@ /* No comment provided by engineer. */ "New member role" = "Új tag szerepköre"; +/* rcv group event chat item */ +"New member wants to join the group." = "Új tag szeretne csatlakozni a csoporthoz."; + /* notification */ "new message" = "új üzenet"; @@ -3256,16 +3526,28 @@ "No app password" = "Nincs alkalmazás jelszó"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs kiválasztva ismerős"; +"No chats" = "Nincsenek csevegések"; /* No comment provided by engineer. */ -"No contacts to add" = "Nincs hozzáadandó ismerős"; +"No chats found" = "Nem találhatók csevegések"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Nincsenek csevegések a(z) %@ nevű listában"; + +/* No comment provided by engineer. */ +"No chats with members" = "Nincsenek csevegések a tagokkal"; + +/* No comment provided by engineer. */ +"No contacts selected" = "Nincs partner kijelölve"; + +/* No comment provided by engineer. */ +"No contacts to add" = "Nincs hozzáadandó partner"; /* No comment provided by engineer. */ "No delivery information" = "Nincs kézbesítési információ"; /* No comment provided by engineer. */ -"No device token!" = "Nincs kiszüléktoken!"; +"No device token!" = "Nincs készüléktoken!"; /* item status description */ "No direct connection yet, message is forwarded by admin." = "Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja."; @@ -3288,6 +3570,9 @@ /* servers error */ "No media & file servers." = "Nincsenek média- és fájlkiszolgálók."; +/* No comment provided by engineer. */ +"No message" = "Nincs üzenet"; + /* servers error */ "No message servers." = "Nincsenek üzenet-kiszolgálók."; @@ -3313,10 +3598,10 @@ "No servers for private message routing." = "Nincsenek kiszolgálók a privát üzenet-útválasztáshoz."; /* servers error */ -"No servers to receive files." = "Nincsenek fájlfogadó-kiszolgálók."; +"No servers to receive files." = "Nincsenek fájlfogadási kiszolgálók."; /* servers error */ -"No servers to receive messages." = "Nincsenek üzenetfogadó-kiszolgálók."; +"No servers to receive messages." = "Nincsenek üzenetfogadási kiszolgálók."; /* servers error */ "No servers to send files." = "Nincsenek fájlküldő-kiszolgálók."; @@ -3324,14 +3609,26 @@ /* copied message info in history */ "no text" = "nincs szöveg"; +/* alert title */ +"No token!" = "Nincs token!"; + /* No comment provided by engineer. */ -"No user identifiers." = "Nincsenek felhasználó-azonosítók."; +"No unread chats" = "Nincsenek olvasatlan csevegések"; + +/* No comment provided by engineer. */ +"No user identifiers." = "Nincsenek felhasználói azonosítók."; /* No comment provided by engineer. */ "Not compatible!" = "Nem kompatibilis!"; /* No comment provided by engineer. */ -"Nothing selected" = "Nincs kiválasztva semmi"; +"not synchronized" = "nincs szinkronizálva"; + +/* No comment provided by engineer. */ +"Notes" = "Jegyzetek"; + +/* No comment provided by engineer. */ +"Nothing selected" = "Nincs semmi kijelölve"; /* alert title */ "Nothing to forward!" = "Nincs mit továbbítani!"; @@ -3342,9 +3639,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Az értesítések le vannak tiltva!"; +/* alert title */ +"Notifications error" = "Értesítési hiba"; + /* No comment provided by engineer. */ "Notifications privacy" = "Értesítési adatvédelem"; +/* alert title */ +"Notifications status" = "Értesítések állapota"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Most már az adminisztrátorok is:\n- törölhetik a tagok üzeneteit.\n- letilthatnak tagokat („megfigyelő” szerepkör)"; @@ -3352,8 +3655,9 @@ "observer" = "megfigyelő"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "kikapcsolva"; /* blur media */ @@ -3363,9 +3667,10 @@ "offered %@" = "%@ ajánlotta"; /* feature offered item */ -"offered %@: %@" = "ajánlotta %1$@: %2$@-kor"; +"offered %@: %@" = "ajánlotta: %1$@, ekkor: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Rendben"; /* No comment provided by engineer. */ @@ -3378,7 +3683,7 @@ "on" = "bekapcsolva"; /* No comment provided by engineer. */ -"One-time invitation link" = "Egyszer használható meghívó-hivatkozás"; +"One-time invitation link" = "Egyszer használható meghívó"; /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Onion-kiszolgálók **szükségesek** a kapcsolódáshoz.\nKompatibilis VPN szükséges."; @@ -3387,31 +3692,37 @@ "Onion hosts will be used when available.\nRequires compatible VPN." = "Onion-kiszolgálók használata, ha azok rendelkezésre állnak.\nVPN engedélyezése szükséges."; /* No comment provided by engineer. */ -"Onion hosts will not be used." = "Onion-kiszolgálók nem lesznek használva."; +"Onion hosts will not be used." = "Az onion-kiszolgálók nem lesznek használva."; /* No comment provided by engineer. */ -"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a beállításokat."; +"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat."; /* No comment provided by engineer. */ -"Only client devices store user profiles, contacts, groups, and messages." = "Csak az eszközök alkalmazásai tárolják a felhasználó-profilokat, névjegyeket, csoportokat és a **2 rétegű végpontok közötti titkosítással** küldött üzeneteket."; +"Only client devices store user profiles, contacts, groups, and messages." = "A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül."; /* No comment provided by engineer. */ "Only delete conversation" = "Csak a beszélgetés törlése"; /* No comment provided by engineer. */ -"Only group owners can change group preferences." = "Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat."; +"Only group owners can change group preferences." = "Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat."; /* No comment provided by engineer. */ -"Only group owners can enable files and media." = "Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését."; +"Only group owners can enable files and media." = "Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését."; /* No comment provided by engineer. */ -"Only group owners can enable voice messages." = "Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését."; +"Only group owners can enable voice messages." = "Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését."; /* No comment provided by engineer. */ -"Only you can add message reactions." = "Csak Ön adhat hozzá üzenetreakciókat."; +"Only sender and moderators see it" = "Csak a küldő és a moderátorok látják"; /* No comment provided by engineer. */ -"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti meg őket ). (24 óra)"; +"Only you and moderators see it" = "Csak Ön és a moderátorok látják"; + +/* No comment provided by engineer. */ +"Only you can add message reactions." = "Csak Ön adhat hozzá reakciókat az üzenetekhez."; + +/* No comment provided by engineer. */ +"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra)"; /* No comment provided by engineer. */ "Only you can make calls." = "Csak Ön tud hívásokat indítani."; @@ -3423,31 +3734,31 @@ "Only you can send voice messages." = "Csak Ön tud hangüzeneteket küldeni."; /* No comment provided by engineer. */ -"Only your contact can add message reactions." = "Csak az ismerőse tud üzenetreakciókat küldeni."; +"Only your contact can add message reactions." = "Csak a partnere adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; +"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; /* No comment provided by engineer. */ -"Only your contact can make calls." = "Csak az ismerőse tud hívást indítani."; +"Only your contact can make calls." = "Csak a partnere tud hívást indítani."; /* No comment provided by engineer. */ -"Only your contact can send disappearing messages." = "Csak az ismerőse tud eltűnő üzeneteket küldeni."; +"Only your contact can send disappearing messages." = "Csak a partnere tud eltűnő üzeneteket küldeni."; /* No comment provided by engineer. */ -"Only your contact can send voice messages." = "Csak az ismerőse tud hangüzeneteket küldeni."; +"Only your contact can send voice messages." = "Csak a partnere tud hangüzeneteket küldeni."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Megnyitás"; /* No comment provided by engineer. */ -"Open changes" = "Változások megnyitása"; +"Open changes" = "Módosítások megtekintése"; /* No comment provided by engineer. */ "Open chat" = "Csevegés megnyitása"; /* authentication reason */ -"Open chat console" = "Csevegés konzol megnyitása"; +"Open chat console" = "Csevegési konzol megnyitása"; /* No comment provided by engineer. */ "Open conditions" = "Feltételek megnyitása"; @@ -3455,8 +3766,11 @@ /* No comment provided by engineer. */ "Open group" = "Csoport megnyitása"; +/* alert title */ +"Open link?" = "Megnyitja a hivatkozást?"; + /* authentication reason */ -"Open migration to another device" = "Átköltöztetés megkezdése egy másik eszközre"; +"Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; /* No comment provided by engineer. */ "Open Settings" = "Beállítások megnyitása"; @@ -3468,7 +3782,7 @@ "Operator" = "Üzemeltető"; /* alert title */ -"Operator server" = "Kiszolgáló üzemeltető"; +"Operator server" = "Kiszolgáló-üzemeltető"; /* No comment provided by engineer. */ "Or import archive file" = "Vagy archívumfájl importálása"; @@ -3488,6 +3802,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "Vagy a privát megosztáshoz"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Csevegések listákba szervezése"; + /* No comment provided by engineer. */ "other" = "egyéb"; @@ -3510,13 +3827,13 @@ "Passcode" = "Jelkód"; /* No comment provided by engineer. */ -"Passcode changed!" = "A jelkód megváltozott!"; +"Passcode changed!" = "A jelkód módosult!"; /* No comment provided by engineer. */ "Passcode entry" = "Jelkód bevitele"; /* No comment provided by engineer. */ -"Passcode not changed!" = "A jelkód nem változott!"; +"Passcode not changed!" = "A jelkód nem módosult!"; /* No comment provided by engineer. */ "Passcode set!" = "A jelkód beállítva!"; @@ -3525,7 +3842,7 @@ "Password" = "Jelszó"; /* No comment provided by engineer. */ -"Password to show" = "Jelszó megjelenítése"; +"Password to show" = "Jelszó a megjelenítéshez"; /* past/unknown group member */ "Past member %@" = "(Már nem tag) %@"; @@ -3543,13 +3860,22 @@ "Paste the link you received" = "Kapott hivatkozás beillesztése"; /* No comment provided by engineer. */ -"peer-to-peer" = "ponttól-pontig"; +"peer-to-peer" = "egyenrangú"; + +/* No comment provided by engineer. */ +"pending" = "függőben"; /* No comment provided by engineer. */ "Pending" = "Függőben"; /* No comment provided by engineer. */ -"Periodic" = "Rendszeresen"; +"pending approval" = "jóváhagyásra vár"; + +/* No comment provided by engineer. */ +"pending review" = "függőben lévő áttekintés"; + +/* No comment provided by engineer. */ +"Periodic" = "Időszakos"; /* message decrypt error item */ "Permanent decryption error" = "Végleges visszafejtési hiba"; @@ -3567,22 +3893,22 @@ "Play from the chat list." = "Lejátszás a csevegési listából."; /* No comment provided by engineer. */ -"Please ask your contact to enable calls." = "Kérje meg az ismerősét, hogy engedélyezze a hívásokat."; +"Please ask your contact to enable calls." = "Kérje meg a partnerét, hogy engedélyezze a hívásokat."; /* No comment provided by engineer. */ -"Please ask your contact to enable sending voice messages." = "Kérje meg az ismerősét, hogy engedélyezze a hangüzenetek küldését."; +"Please ask your contact to enable sending voice messages." = "Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését."; /* No comment provided by engineer. */ "Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers." = "Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel."; /* No comment provided by engineer. */ -"Please check that you used the correct link or ask your contact to send you another one." = "Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat."; +"Please check that you used the correct link or ask your contact to send you another one." = "Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat."; /* No comment provided by engineer. */ -"Please check your network connection with %@ and try again." = "Ellenőrizze a hálózati kapcsolatát a következővel: %@, és próbálja újra."; +"Please check your network connection with %@ and try again." = "Ellenőrizze a hálózati kapcsolatát a vele: %@, és próbálja újra."; /* No comment provided by engineer. */ -"Please check yours and your contact preferences." = "Ellenőrizze a saját- és az ismerőse beállításait."; +"Please check yours and your contact preferences." = "Ellenőrizze a saját- és a partnere beállításait."; /* No comment provided by engineer. */ "Please confirm that network settings are correct for this device." = "Ellenőrizze, hogy a hálózati beállítások megfelelők-e ehhez az eszközhöz."; @@ -3594,13 +3920,13 @@ "Please contact group admin." = "Lépjen kapcsolatba a csoport adminisztrátorával."; /* No comment provided by engineer. */ -"Please enter correct current passphrase." = "Adja meg a helyes, jelenlegi jelmondatát."; +"Please enter correct current passphrase." = "Adja meg a helyes, jelenlegi jelmondatot."; /* No comment provided by engineer. */ -"Please enter the previous password after restoring database backup. This action can not be undone." = "Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza."; +"Please enter the previous password after restoring database backup. This action can not be undone." = "Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza."; /* No comment provided by engineer. */ -"Please remember or store it securely - there is no way to recover a lost passcode!" = "Jegyezze fel vagy tárolja el biztonságosan - az elveszett jelkódot nem lehet visszaállítani!"; +"Please remember or store it securely - there is no way to recover a lost passcode!" = "Jegyezze fel vagy tárolja el biztonságosan – az elveszett jelkódot nem lehet visszaállítani!"; /* No comment provided by engineer. */ "Please report it to the developers." = "Jelentse a fejlesztőknek."; @@ -3612,7 +3938,19 @@ "Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez."; /* No comment provided by engineer. */ -"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni."; +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja módosítani."; + +/* token info */ +"Please try to disable and re-enable notfications." = "Próbálja meg letiltani és újra engedélyezni az értesítéseket."; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérelmét."; + +/* token info */ +"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása befejeződik."; + +/* token info */ +"Please wait for token to be registered." = "Várjon a token regisztrálására."; /* No comment provided by engineer. */ "Polish interface" = "Lengyel kezelőfelület"; @@ -3627,7 +3965,7 @@ "Preserve the last message draft, with attachments." = "Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt."; /* No comment provided by engineer. */ -"Preset server address" = "Előre beállított kiszolgáló címe"; +"Preset server address" = "Az előre beállított kiszolgáló címe"; /* No comment provided by engineer. */ "Preset servers" = "Előre beállított kiszolgálók"; @@ -3642,14 +3980,23 @@ "Privacy & security" = "Adatvédelem és biztonság"; /* No comment provided by engineer. */ -"Privacy for your customers." = "Az Ön ügyfeleinek adatvédelme."; +"Privacy for your customers." = "Saját ügyfeleinek adatvédelme."; /* No comment provided by engineer. */ -"Privacy redefined" = "Adatvédelem újraértelmezve"; +"Privacy policy and conditions of use." = "Adatvédelmi szabályzat és felhasználási feltételek."; + +/* No comment provided by engineer. */ +"Privacy redefined" = "Újraértelmezett adatvédelem"; + +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára."; /* No comment provided by engineer. */ "Private filenames" = "Privát fájlnevek"; +/* No comment provided by engineer. */ +"Private media file names." = "Privát nevek a médiafájlokhoz."; + /* No comment provided by engineer. */ "Private message routing" = "Privát üzenet-útválasztás"; @@ -3663,7 +4010,7 @@ "Private routing" = "Privát útválasztás"; /* No comment provided by engineer. */ -"Private routing error" = "Privát útválasztáshiba"; +"Private routing error" = "Privát útválasztási hiba"; /* No comment provided by engineer. */ "Profile and server connections" = "Profil és kiszolgálókapcsolatok"; @@ -3681,19 +4028,22 @@ "Profile theme" = "Profiltéma"; /* alert message */ -"Profile update will be sent to your contacts." = "A profilfrissítés elküldésre került az ismerősök számára."; +"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve a partnerei számára."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "A hívások kezdeményezése le van tiltva."; /* No comment provided by engineer. */ -"Prohibit irreversible message deletion." = "Az üzenetek véglegesen való törlése le van tiltva."; +"Prohibit irreversible message deletion." = "Az elküldött üzenetek végleges törlése le van tiltva."; /* No comment provided by engineer. */ -"Prohibit message reactions." = "Az üzenetreakciók küldése le van tiltva."; +"Prohibit message reactions." = "A reakciók hozzáadása az üzenethez le van tiltva."; /* No comment provided by engineer. */ -"Prohibit messages reactions." = "Az üzenetreakciók tiltása."; +"Prohibit messages reactions." = "A reakciók hozzáadása az üzenetekhez le van tiltva."; + +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Az üzenetek a moderátorok felé történő jelentésének megtiltása."; /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között le van tiltva."; @@ -3702,7 +4052,7 @@ "Prohibit sending disappearing messages." = "Az eltűnő üzenetek küldése le van tiltva."; /* No comment provided by engineer. */ -"Prohibit sending files and media." = "Fájlok- és a médiatartalmak küldésének letiltása."; +"Prohibit sending files and media." = "A fájlok- és a médiatartalmak küldése le van tiltva."; /* No comment provided by engineer. */ "Prohibit sending SimpleX links." = "A SimpleX-hivatkozások küldése le van tiltva."; @@ -3720,13 +4070,13 @@ "Protect your chat profiles with a password!" = "Védje meg a csevegési profiljait egy jelszóval!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben."; /* No comment provided by engineer. */ "Protocol timeout" = "Protokoll időtúllépése"; /* No comment provided by engineer. */ -"Protocol timeout per KB" = "Protokoll időtúllépése KB-onként"; +"Protocol timeout per KB" = "Protokoll időtúllépése kB-onként"; /* No comment provided by engineer. */ "Proxied" = "Proxyzott"; @@ -3744,10 +4094,10 @@ "Push server" = "Push-kiszolgáló"; /* chat item text */ -"quantum resistant e2e encryption" = "végpontok közötti kvantumrezisztens titkosítás"; +"quantum resistant e2e encryption" = "végpontok közötti kvantumbiztos titkosítás"; /* No comment provided by engineer. */ -"Quantum resistant encryption" = "Kvantumrezisztens titkosítás"; +"Quantum resistant encryption" = "Kvantumbiztos titkosítás"; /* No comment provided by engineer. */ "Rate the app" = "Értékelje az alkalmazást"; @@ -3786,10 +4136,10 @@ "received answer…" = "válasz fogadása…"; /* No comment provided by engineer. */ -"Received at" = "Fogadva ekkor:"; +"Received at" = "Fogadva"; /* copied message info */ -"Received at: %@" = "Fogadva ekkor: %@"; +"Received at: %@" = "Fogadva: %@"; /* No comment provided by engineer. */ "received confirmation…" = "visszaigazolás fogadása…"; @@ -3810,13 +4160,13 @@ "Received total" = "Összes fogadott üzenet"; /* No comment provided by engineer. */ -"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "A fogadó cím egy másik kiszolgálóra változik. A címváltoztatás a feladó online állapotba kerülése után fejeződik be."; +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "A fogadási cím egy másik kiszolgálóra fog módosulni. A cím módosítása a feladó online állapotba kerülése után fejeződik be."; /* No comment provided by engineer. */ "Receiving file will be stopped." = "A fájl fogadása le fog állni."; /* No comment provided by engineer. */ -"Receiving via" = "Fogadás a"; +"Receiving via" = "Fogadás a következőn keresztül:"; /* No comment provided by engineer. */ "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)." = "Legutóbbi előzmények és továbbfejlesztett [könyvtárbot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)."; @@ -3837,50 +4187,65 @@ "Reconnect all servers" = "Újrakapcsolódás az összes kiszolgálóhoz"; /* No comment provided by engineer. */ -"Reconnect all servers?" = "Újrakapcsolódás az összes kiszolgálóhoz?"; +"Reconnect all servers?" = "Újrakapcsolódik az összes kiszolgálóhoz?"; /* No comment provided by engineer. */ "Reconnect server to force message delivery. It uses additional traffic." = "A kiszolgálóhoz való újrakapcsolódás az üzenetkézbesítési jelentések kikényszerítéséhez. Ez további adatforgalmat használ."; /* No comment provided by engineer. */ -"Reconnect server?" = "Újrakapcsolódás a kiszolgálóhoz?"; +"Reconnect server?" = "Újrakapcsolódik a kiszolgálóhoz?"; /* No comment provided by engineer. */ -"Reconnect servers?" = "Újrakapcsolódás a kiszolgálókhoz?"; +"Reconnect servers?" = "Újrakapcsolódik a kiszolgálókhoz?"; /* No comment provided by engineer. */ -"Record updated at" = "A bejegyzés frissítve"; +"Record updated at" = "Bejegyzés frissítve"; /* copied message info */ -"Record updated at: %@" = "A bejegyzés frissítve: %@"; +"Record updated at: %@" = "Bejegyzés frissítve: %@"; /* No comment provided by engineer. */ "Reduced battery usage" = "Csökkentett akkumulátor-használat"; +/* No comment provided by engineer. */ +"Register" = "Regisztrálás"; + +/* token info */ +"Register notification token?" = "Regisztrálja az értesítési tokent?"; + +/* token status text */ +"Registered" = "Regisztrálva"; + /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Elutasítás"; /* No comment provided by engineer. */ "Reject (sender NOT notified)" = "Elutasítás (a feladó NEM kap értesítést)"; /* No comment provided by engineer. */ -"Reject contact request" = "Kapcsolatkérés elutasítása"; +"Reject contact request" = "Meghívási kérés elutasítása"; + +/* alert title */ +"Reject member?" = "Elutasítja a tagot?"; + +/* No comment provided by engineer. */ +"rejected" = "elutasítva"; /* call status */ "rejected call" = "elutasított hívás"; /* No comment provided by engineer. */ -"Relay server is only used if necessary. Another party can observe your IP address." = "A közvetítő-kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet."; +"Relay server is only used if necessary. Another party can observe your IP address." = "A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét."; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "A közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát."; +"Relay server protects your IP address, but it can observe the duration of the call." = "A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; /* No comment provided by engineer. */ "Remove" = "Eltávolítás"; /* No comment provided by engineer. */ -"Remove archive?" = "Archívum eltávolítása?"; +"Remove archive?" = "Eltávolítja az archívumot?"; /* No comment provided by engineer. */ "Remove image" = "Kép eltávolítása"; @@ -3889,10 +4254,10 @@ "Remove member" = "Eltávolítás"; /* No comment provided by engineer. */ -"Remove member?" = "Biztosan eltávolítja?"; +"Remove member?" = "Eltávolítja a tagot?"; /* No comment provided by engineer. */ -"Remove passphrase from keychain?" = "Jelmondat eltávolítása a kulcstartóból?"; +"Remove passphrase from keychain?" = "Eltávolítja a jelmondatot a kulcstartóból?"; /* No comment provided by engineer. */ "removed" = "eltávolítva"; @@ -3903,6 +4268,9 @@ /* profile update event chat item */ "removed contact address" = "eltávolította a kapcsolattartási címet"; +/* No comment provided by engineer. */ +"removed from group" = "eltávolítva a csoportból"; + /* profile update event chat item */ "removed profile picture" = "eltávolította a profilképét"; @@ -3910,16 +4278,16 @@ "removed you" = "eltávolította Önt"; /* No comment provided by engineer. */ -"Renegotiate" = "Újraegyzetetés"; +"Renegotiate" = "Újraegyeztetés"; /* No comment provided by engineer. */ "Renegotiate encryption" = "Titkosítás újraegyeztetése"; /* No comment provided by engineer. */ -"Renegotiate encryption?" = "Titkosítás újraegyeztetése?"; +"Renegotiate encryption?" = "Újraegyezteti a titkosítást?"; /* No comment provided by engineer. */ -"Repeat connection request?" = "Kapcsolatkérés megismétlése?"; +"Repeat connection request?" = "Megismétli a meghívási kérést?"; /* No comment provided by engineer. */ "Repeat download" = "Letöltés ismét"; @@ -3928,7 +4296,7 @@ "Repeat import" = "Importálás ismét"; /* No comment provided by engineer. */ -"Repeat join request?" = "Csatlakozáskérés megismétlése?"; +"Repeat join request?" = "Megismétli a meghívási kérést?"; /* No comment provided by engineer. */ "Repeat upload" = "Feltöltés ismét"; @@ -3936,8 +4304,44 @@ /* chat item action */ "Reply" = "Válasz"; +/* chat item action */ +"Report" = "Jelentés"; + +/* report reason */ +"Report content: only group moderators will see it." = "Tartalom jelentése: csak a csoport moderátorai látják."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Tag profiljának jelentése: csak a csoport moderátorai látják."; + +/* report reason */ +"Report other: only group moderators will see it." = "Egyéb jelentés: csak a csoport moderátorai látják."; + +/* No comment provided by engineer. */ +"Report reason?" = "Jelentés indoklása?"; + +/* alert title */ +"Report sent to moderators" = "A jelentés el lett küldve a moderátoroknak"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Kéretlen tartalom jelentése: csak a csoport moderátorai látják."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Szabálysértés jelentése: csak a csoport moderátorai látják."; + +/* report in notification */ +"Report: %@" = "Jelentés: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Az üzenetek jelentése a moderátorok felé le van tiltva."; + +/* No comment provided by engineer. */ +"Reports" = "Jelentések"; + +/* No comment provided by engineer. */ +"request to join rejected" = "csatlakozási kérelem elutasítva"; + /* chat list item title */ -"requested to connect" = "kérelmezve a kapcsolódáshoz"; +"requested to connect" = "Függőben lévő meghívási kérelem"; /* No comment provided by engineer. */ "Required" = "Szükséges"; @@ -3952,7 +4356,7 @@ "Reset all statistics" = "Az összes statisztika visszaállítása"; /* No comment provided by engineer. */ -"Reset all statistics?" = "Az összes statisztika visszaállítása?"; +"Reset all statistics?" = "Visszaállítja az összes statisztikát?"; /* No comment provided by engineer. */ "Reset colors" = "Színek visszaállítása"; @@ -3979,10 +4383,10 @@ "Restore database backup" = "Adatbázismentés visszaállítása"; /* No comment provided by engineer. */ -"Restore database backup?" = "Adatbázismentés visszaállítása?"; +"Restore database backup?" = "Visszaállítja az adatbázismentést?"; /* No comment provided by engineer. */ -"Restore database error" = "Hiba az adatbázis visszaállításakor"; +"Restore database error" = "Hiba történt az adatbázis visszaállításakor"; /* No comment provided by engineer. */ "Retry" = "Újrapróbálkozás"; @@ -3991,10 +4395,19 @@ "Reveal" = "Felfedés"; /* No comment provided by engineer. */ -"Review conditions" = "Feltételek felülvizsgálata"; +"review" = "áttekintés"; /* No comment provided by engineer. */ -"Review later" = "Felülvizsgálat később"; +"Review conditions" = "Feltételek felülvizsgálata"; + +/* admission stage */ +"Review members" = "Tagok áttekintése"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Tagok áttekintése a befogadás előtt (kopogtatás)."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "áttekintve a moderátorok által"; /* No comment provided by engineer. */ "Revoke" = "Visszavonás"; @@ -4003,7 +4416,7 @@ "Revoke file" = "Fájl visszavonása"; /* No comment provided by engineer. */ -"Revoke file?" = "Fájl visszavonása?"; +"Revoke file?" = "Visszavonja a fájlt?"; /* No comment provided by engineer. */ "Role" = "Szerepkör"; @@ -4018,14 +4431,17 @@ "Safer groups" = "Biztonságosabb csoportok"; /* alert button - chat item action */ +chat item action */ "Save" = "Mentés"; /* alert button */ -"Save (and notify contacts)" = "Mentés és az ismerősök értesítése"; +"Save (and notify contacts)" = "Mentés (és a partnerek értesítése)"; + +/* alert title */ +"Save admission settings?" = "Elmenti a befogadási beállításokat?"; /* alert button */ -"Save and notify contact" = "Mentés és az ismerős értesítése"; +"Save and notify contact" = "Mentés és a partner értesítése"; /* No comment provided by engineer. */ "Save and notify group members" = "Mentés és a csoporttagok értesítése"; @@ -4039,6 +4455,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Csoportprofil mentése"; +/* No comment provided by engineer. */ +"Save list" = "Lista mentése"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Jelmondat mentése és a csevegés megnyitása"; @@ -4046,7 +4465,7 @@ "Save passphrase in Keychain" = "Jelmondat mentése a kulcstartóba"; /* alert title */ -"Save preferences?" = "Beállítások mentése?"; +"Save preferences?" = "Menti a beállításokat?"; /* No comment provided by engineer. */ "Save profile password" = "Profiljelszó mentése"; @@ -4055,13 +4474,13 @@ "Save servers" = "Kiszolgálók mentése"; /* alert title */ -"Save servers?" = "Kiszolgálók mentése?"; +"Save servers?" = "Menti a kiszolgálókat?"; /* No comment provided by engineer. */ -"Save welcome message?" = "Üdvözlőüzenet mentése?"; +"Save welcome message?" = "Menti az üdvözlőüzenetet?"; /* alert title */ -"Save your profile?" = "Profil mentése?"; +"Save your profile?" = "Menti a profilt?"; /* No comment provided by engineer. */ "saved" = "mentett"; @@ -4070,7 +4489,7 @@ "Saved" = "Mentett"; /* No comment provided by engineer. */ -"Saved from" = "Elmentve innen:"; +"Saved from" = "Elmentve innen"; /* No comment provided by engineer. */ "saved from %@" = "elmentve innen: %@"; @@ -4079,7 +4498,7 @@ "Saved message" = "Mentett üzenet"; /* No comment provided by engineer. */ -"Saved WebRTC ICE servers will be removed" = "A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek"; +"Saved WebRTC ICE servers will be removed" = "A mentett WebRTC ICE-kiszolgálók el lesznek távolítva"; /* No comment provided by engineer. */ "Saving %lld messages" = "%lld üzenet mentése"; @@ -4097,10 +4516,10 @@ "Scan QR code" = "QR-kód beolvasása"; /* No comment provided by engineer. */ -"Scan QR code from desktop" = "QR-kód beolvasása számítógépről"; +"Scan QR code from desktop" = "QR-kód beolvasása a számítógépről"; /* No comment provided by engineer. */ -"Scan security code from your contact's app." = "Biztonsági kód beolvasása az ismerősének alkalmazásából."; +"Scan security code from your contact's app." = "Biztonsági kód beolvasása a partnere alkalmazásából."; /* No comment provided by engineer. */ "Scan server QR code" = "A kiszolgáló QR-kódjának beolvasása"; @@ -4112,7 +4531,7 @@ "Search" = "Keresés"; /* No comment provided by engineer. */ -"Search bar accepts invitation links." = "A keresősáv elfogadja a meghívó-hivatkozásokat."; +"Search bar accepts invitation links." = "A keresősáv elfogadja a meghívási hivatkozásokat."; /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Keresés vagy SimpleX-hivatkozás beillesztése"; @@ -4121,7 +4540,7 @@ "sec" = "mp"; /* No comment provided by engineer. */ -"Secondary" = "Másodlagos"; +"Secondary" = "Másodlagos szín"; /* time unit */ "seconds" = "másodperc"; @@ -4130,7 +4549,7 @@ "secret" = "titok"; /* server test step */ -"Secure queue" = "Biztonságos sorbaállítás"; +"Secure queue" = "Biztonságos sorba állítás"; /* No comment provided by engineer. */ "Secured" = "Biztosítva"; @@ -4142,37 +4561,37 @@ "Security code" = "Biztonsági kód"; /* chat item text */ -"security code changed" = "a biztonsági kód megváltozott"; +"security code changed" = "a biztonsági kód módosult"; /* chat item action */ -"Select" = "Kiválasztás"; +"Select" = "Kijelölés"; /* No comment provided by engineer. */ -"Select chat profile" = "Csevegési profil kiválasztása"; +"Select chat profile" = "Csevegési profil kijelölése"; /* No comment provided by engineer. */ -"Selected %lld" = "%lld kiválasztva"; +"Selected %lld" = "%lld kijelölve"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; /* No comment provided by engineer. */ -"Self-destruct passcode" = "Önmegsemmisítési jelkód"; +"Self-destruct passcode" = "Önmegsemmisítő-jelkód"; /* No comment provided by engineer. */ -"Self-destruct passcode changed!" = "Az önmegsemmisítési jelkód megváltozott!"; +"Self-destruct passcode changed!" = "Az önmegsemmisítő-jelkód módosult!"; /* No comment provided by engineer. */ -"Self-destruct passcode enabled!" = "Az önmegsemmisítési jelkód engedélyezve!"; +"Self-destruct passcode enabled!" = "Az önmegsemmisítő-jelkód engedélyezve!"; /* No comment provided by engineer. */ "Send" = "Küldés"; /* No comment provided by engineer. */ -"Send a live message - it will update for the recipient(s) as you type it" = "Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja"; +"Send a live message - it will update for the recipient(s) as you type it" = "Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet"; /* No comment provided by engineer. */ "Send delivery receipts to" = "A kézbesítési jelentéseket a következő címre kell küldeni"; @@ -4190,7 +4609,7 @@ "Send errors" = "Üzenetküldési hibák"; /* No comment provided by engineer. */ -"Send link previews" = "Hivatkozás előnézetek küldése"; +"Send link previews" = "Hivatkozás előnézete"; /* No comment provided by engineer. */ "Send live message" = "Élő üzenet küldése"; @@ -4199,61 +4618,64 @@ "Send message to enable calls." = "Üzenet küldése a hívások engedélyezéséhez."; /* No comment provided by engineer. */ -"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha az IP-cím védett és az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; /* No comment provided by engineer. */ -"Send messages directly when your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Send messages directly when your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; /* No comment provided by engineer. */ "Send notifications" = "Értesítések küldése"; /* No comment provided by engineer. */ -"Send questions and ideas" = "Ötletek és kérdések beküldése"; +"Send private reports" = "Privát jelentések küldése"; + +/* No comment provided by engineer. */ +"Send questions and ideas" = "Ötletek és javaslatok"; /* No comment provided by engineer. */ "Send receipts" = "Kézbesítési jelentések küldése"; /* No comment provided by engineer. */ -"Send them from gallery or custom keyboards." = "Küldje el őket galériából vagy egyedi billentyűzetekről."; +"Send them from gallery or custom keyboards." = "Küldje el őket a galériából vagy az egyéni billentyűzetekről."; /* No comment provided by engineer. */ -"Send up to 100 last messages to new members." = "Az utolsó 100 üzenet elküldése az új tagoknak."; +"Send up to 100 last messages to new members." = "Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára."; /* alert message */ "Sender cancelled file transfer." = "A fájl küldője visszavonta az átvitelt."; /* No comment provided by engineer. */ -"Sender may have deleted the connection request." = "A küldő törölhette a kapcsolatkérést."; +"Sender may have deleted the connection request." = "A küldője törölhette a meghívási kérést."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára."; +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül."; +"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz."; /* No comment provided by engineer. */ "Sending file will be stopped." = "A fájl küldése le fog állni."; /* No comment provided by engineer. */ -"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld ismerősnél"; +"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partnernél"; /* No comment provided by engineer. */ "Sending receipts is disabled for %lld groups" = "A kézbesítési jelentések le vannak tiltva %lld csoportban"; /* No comment provided by engineer. */ -"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld ismerősnél"; +"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partnernél"; /* No comment provided by engineer. */ "Sending receipts is enabled for %lld groups" = "A kézbesítési jelentések engedélyezve vannak %lld csoportban"; /* No comment provided by engineer. */ -"Sending via" = "Küldés ezen keresztül"; +"Sending via" = "Küldés a következőn keresztül:"; /* No comment provided by engineer. */ -"Sent at" = "Elküldve ekkor:"; +"Sent at" = "Elküldve"; /* copied message info */ -"Sent at: %@" = "Elküldve ekkor: %@"; +"Sent at: %@" = "Elküldve: %@"; /* No comment provided by engineer. */ "Sent directly" = "Közvetlenül küldött"; @@ -4268,7 +4690,7 @@ "Sent messages" = "Elküldött üzenetek"; /* No comment provided by engineer. */ -"Sent messages will be deleted after set time." = "Az elküldött üzenetek törlésre kerülnek a beállított idő után."; +"Sent messages will be deleted after set time." = "Az elküldött üzenetek törölve lesznek a beállított idő után."; /* No comment provided by engineer. */ "Sent reply" = "Válaszüzenet-buborék színe"; @@ -4277,7 +4699,7 @@ "Sent total" = "Összes elküldött üzenet"; /* No comment provided by engineer. */ -"Sent via proxy" = "Proxyn keresztül küldve"; +"Sent via proxy" = "Proxyn keresztül küldött"; /* No comment provided by engineer. */ "Server" = "Kiszolgáló"; @@ -4295,22 +4717,22 @@ "Server address is incompatible with network settings." = "A kiszolgáló címe nem kompatibilis a hálózati beállításokkal."; /* alert title */ -"Server operator changed." = "A kiszolgáló üzemeltetője megváltozott."; +"Server operator changed." = "A kiszolgáló üzemeltetője módosult."; /* No comment provided by engineer. */ -"Server operators" = "Kiszolgáló-üzemeltetők"; +"Server operators" = "Kiszolgálóüzemeltetők"; /* alert title */ -"Server protocol changed." = "A kiszolgáló-protokoll megváltozott."; +"Server protocol changed." = "A kiszolgáló-protokoll módosult."; /* queue info */ -"server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló üzenet-sorbaállítási információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; +"server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló sorbaállítási információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "A kiszolgálónak engedélyre van szüksége a sorbaállítás létrehozásához, ellenőrizze jelszavát"; +"Server requires authorization to create queues, check password" = "A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát"; /* server test error */ -"Server requires authorization to upload, check password" = "A kiszolgálónak engedélyre van szüksége a várólisták feltöltéséhez, ellenőrizze jelszavát"; +"Server requires authorization to upload, check password" = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát"; /* No comment provided by engineer. */ "Server test failed!" = "Sikertelen kiszolgáló teszt!"; @@ -4331,7 +4753,7 @@ "Servers info" = "Információk a kiszolgálókról"; /* No comment provided by engineer. */ -"Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza!"; +"Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Session code" = "Munkamenet kód"; @@ -4340,7 +4762,10 @@ "Set 1 day" = "Beállítva 1 nap"; /* No comment provided by engineer. */ -"Set contact name…" = "Ismerős nevének beállítása…"; +"Set chat name…" = "Csevegés nevének beállítása…"; + +/* No comment provided by engineer. */ +"Set contact name…" = "Partner nevének beállítása…"; /* No comment provided by engineer. */ "Set default theme" = "Alapértelmezett téma beállítása"; @@ -4349,10 +4774,16 @@ "Set group preferences" = "Csoportbeállítások megadása"; /* No comment provided by engineer. */ -"Set it instead of system authentication." = "Rendszerhitelesítés helyetti beállítás."; +"Set it instead of system authentication." = "Beállítás a rendszer-hitelesítés helyett."; + +/* No comment provided by engineer. */ +"Set member admission" = "Tagbefogadás beállítása"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Üzenetek eltűnési idejének módosítása a csevegésekben."; /* profile update event chat item */ -"set new contact address" = "új kapcsolattartási cím beállítása"; +"set new contact address" = "új kapcsolattartási címet állított be"; /* profile update event chat item */ "set new profile picture" = "új profilképet állított be"; @@ -4367,7 +4798,7 @@ "Set passphrase to export" = "Jelmondat beállítása az exportáláshoz"; /* No comment provided by engineer. */ -"Set the message shown to new members!" = "Megjelenő üzenet beállítása az új tagok számára!"; +"Set the message shown to new members!" = "Megjelenítendő üzenet beállítása az új tagok számára!"; /* No comment provided by engineer. */ "Set timeouts for proxy/VPN" = "Időtúllépések beállítása a proxy/VPN számára"; @@ -4376,20 +4807,20 @@ "Settings" = "Beállítások"; /* alert message */ -"Settings were changed." = "A beállítások megváltoztak."; +"Settings were changed." = "A beállítások módosultak."; /* No comment provided by engineer. */ -"Shape profile images" = "Profilkép alakzat"; +"Shape profile images" = "Profilkép alakzata"; /* alert action - chat item action */ +chat item action */ "Share" = "Megosztás"; /* No comment provided by engineer. */ -"Share 1-time link" = "Egyszer használható hivatkozás megosztása"; +"Share 1-time link" = "Egyszer használható meghívó megosztása"; /* No comment provided by engineer. */ -"Share 1-time link with a friend" = "Egyszer használható meghívó-hivatkozás megosztása egy baráttal"; +"Share 1-time link with a friend" = "Egyszer használható meghívó megosztása egy baráttal"; /* No comment provided by engineer. */ "Share address" = "Cím megosztása"; @@ -4398,13 +4829,13 @@ "Share address publicly" = "Cím nyilvános megosztása"; /* alert title */ -"Share address with contacts?" = "Megosztja a címet az ismerőseivel?"; +"Share address with contacts?" = "Megosztja a címet a partnereivel?"; /* No comment provided by engineer. */ "Share from other apps." = "Megosztás más alkalmazásokból."; /* No comment provided by engineer. */ -"Share link" = "Hivatkozás megosztása"; +"Share link" = "Megosztás"; /* No comment provided by engineer. */ "Share profile" = "Profil megosztása"; @@ -4413,13 +4844,16 @@ "Share SimpleX address on social media." = "SimpleX-cím megosztása a közösségi médiában."; /* No comment provided by engineer. */ -"Share this 1-time invite link" = "Egyszer használható meghívó-hivatkozás megosztása"; +"Share this 1-time invite link" = "Ennek az egyszer használható meghívónak a megosztása"; /* No comment provided by engineer. */ -"Share to SimpleX" = "Megosztás a SimpleX-ben"; +"Share to SimpleX" = "Megosztás a SimpleXben"; /* No comment provided by engineer. */ -"Share with contacts" = "Megosztás az ismerősökkel"; +"Share with contacts" = "Megosztás a partnerekkel"; + +/* No comment provided by engineer. */ +"Short link" = "Rövid hivatkozás"; /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Egy „→” jel megjelenítése a privát útválasztáson keresztül küldött üzeneteknél."; @@ -4431,10 +4865,10 @@ "Show developer options" = "Fejlesztői beállítások megjelenítése"; /* No comment provided by engineer. */ -"Show last messages" = "Szobák utolsó üzeneteinek megjelenítése a listanézetben"; +"Show last messages" = "Legutóbbi üzenet előnézetének megjelenítése"; /* No comment provided by engineer. */ -"Show message status" = "Üzenetállapot megjelenítése"; +"Show message status" = "Üzenet állapotának megjelenítése"; /* No comment provided by engineer. */ "Show percentage" = "Százalék megjelenítése"; @@ -4446,7 +4880,7 @@ "Show QR code" = "QR-kód megjelenítése"; /* No comment provided by engineer. */ -"Show:" = "Megjelenítés:"; +"Show:" = "Megjelenítve:"; /* No comment provided by engineer. */ "SimpleX" = "SimpleX"; @@ -4458,10 +4892,13 @@ "SimpleX Address" = "SimpleX-cím"; /* No comment provided by engineer. */ -"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül."; +"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül."; /* No comment provided by engineer. */ -"SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó-hivatkozás?"; +"SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó?"; + +/* simplex link type */ +"SimpleX channel link" = "SimpleX-csatornahivatkozás"; /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba."; @@ -4482,7 +4919,7 @@ "SimpleX links" = "SimpleX-hivatkozások"; /* No comment provided by engineer. */ -"SimpleX links are prohibited." = "A SimpleX-hivatkozások küldése le van tiltva ebben a csoportban."; +"SimpleX links are prohibited." = "A SimpleX-hivatkozások küldése le van tiltva."; /* No comment provided by engineer. */ "SimpleX links not allowed" = "A SimpleX-hivatkozások küldése le van tiltva"; @@ -4500,10 +4937,10 @@ "SimpleX Lock turned on" = "SimpleX-zár bekapcsolva"; /* simplex link type */ -"SimpleX one-time invitation" = "Egyszer használható SimpleX-meghívó-hivatkozás"; +"SimpleX one-time invitation" = "Egyszer használható SimpleX-meghívó"; /* No comment provided by engineer. */ -"SimpleX protocols reviewed by Trail of Bits." = "A SimpleX Chat biztonsága a Trail of Bits által lett felülvizsgálva."; +"SimpleX protocols reviewed by Trail of Bits." = "A SimpleX-protokollokat a Trail of Bits auditálta."; /* No comment provided by engineer. */ "Simplified incognito mode" = "Egyszerűsített inkognitómód"; @@ -4524,7 +4961,7 @@ "SMP server" = "SMP-kiszolgáló"; /* No comment provided by engineer. */ -"SOCKS proxy" = "SOCKS proxy"; +"SOCKS proxy" = "SOCKS-proxy"; /* blur media */ "Soft" = "Enyhe"; @@ -4533,7 +4970,7 @@ "Some app settings were not migrated." = "Egyes alkalmazásbeállítások nem lettek átköltöztetve."; /* No comment provided by engineer. */ -"Some file(s) were not exported:" = "Néhány fájl nem került exportálásra:"; +"Some file(s) were not exported:" = "Néhány fájl nem lett exportálva:"; /* No comment provided by engineer. */ "Some non-fatal errors occurred during import - you may see Chat console for more details." = "Néhány nem végzetes hiba történt az importáláskor – további részleteket a csevegési konzolban olvashat."; @@ -4547,6 +4984,10 @@ /* notification title */ "Somebody" = "Valaki"; +/* blocking reason +report reason */ +"Spam" = "Kéretlen tartalom"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Négyzet, kör vagy bármi a kettő között."; @@ -4557,13 +4998,13 @@ "Start chat" = "Csevegés indítása"; /* No comment provided by engineer. */ -"Start chat?" = "Csevegés indítása?"; +"Start chat?" = "Elindítja a csevegést?"; /* No comment provided by engineer. */ "Start migration" = "Átköltöztetés indítása"; /* No comment provided by engineer. */ -"Starting from %@." = "Kezdve ettől %@."; +"Starting from %@." = "Statisztikagyűjtés kezdete: %@."; /* No comment provided by engineer. */ "starting…" = "indítás…"; @@ -4581,22 +5022,22 @@ "Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "A csevegés megállítása a csevegési adatbázis exportálásához, importálásához vagy törléséhez. A csevegés megállításakor nem tud üzeneteket fogadni és küldeni."; /* No comment provided by engineer. */ -"Stop chat?" = "Csevegési szolgáltatás megállítása?"; +"Stop chat?" = "Megállítja a csevegést?"; /* cancel file action */ "Stop file" = "Fájl megállítása"; /* No comment provided by engineer. */ -"Stop receiving file?" = "Fájlfogadás megállítása?"; +"Stop receiving file?" = "Megállítja a fájlfogadást?"; /* No comment provided by engineer. */ -"Stop sending file?" = "Fájlküldés megállítása?"; +"Stop sending file?" = "Megállítja a fájlküldést?"; /* alert action */ "Stop sharing" = "Megosztás megállítása"; /* alert title */ -"Stop sharing address?" = "Címmegosztás megállítása?"; +"Stop sharing address?" = "Megállítja a címmegosztást?"; /* authentication reason */ "Stop SimpleX" = "SimpleX megállítása"; @@ -4604,6 +5045,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Csevegés megállítása folyamatban"; +/* No comment provided by engineer. */ +"Storage" = "Tárhely"; + /* No comment provided by engineer. */ "strike" = "áthúzott"; @@ -4629,13 +5073,13 @@ "Switch audio and video during the call." = "Hang/Videó váltása hívás közben."; /* No comment provided by engineer. */ -"Switch chat profile for 1-time invitations." = "Csevegési profilváltás az egyszer használható meghívó-hivatkozásokhoz."; +"Switch chat profile for 1-time invitations." = "Csevegési profilváltás az egyszer használható meghívókhoz."; /* No comment provided by engineer. */ "System" = "Rendszer"; /* No comment provided by engineer. */ -"System authentication" = "Rendszerhitelesítés"; +"System authentication" = "Rendszer-hitelesítés"; /* No comment provided by engineer. */ "Tail" = "Farok"; @@ -4659,19 +5103,22 @@ "Tap to join" = "Koppintson ide a csatlakozáshoz"; /* No comment provided by engineer. */ -"Tap to join incognito" = "Koppintson ide az inkognitóban való csatlakozáshoz"; +"Tap to join incognito" = "Koppintson ide az inkognitóban való kapcsolódáshoz"; /* No comment provided by engineer. */ "Tap to paste link" = "Koppintson ide a hivatkozás beillesztéséhez"; /* No comment provided by engineer. */ -"Tap to scan" = "Koppintson ide a QR-kód beolvasáshoz"; +"Tap to scan" = "Koppintson ide a QR-kód beolvasásához"; /* No comment provided by engineer. */ -"TCP connection" = "TCP kapcsolat"; +"TCP connection" = "TCP-kapcsolat"; /* No comment provided by engineer. */ -"TCP connection timeout" = "TCP kapcsolat időtúllépése"; +"TCP connection timeout" = "TCP-kapcsolat időtúllépése"; + +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-port az üzenetváltáshoz"; /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4682,11 +5129,14 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ -"Temporary file error" = "Ideiglenesfájl-hiba"; +/* file error alert title */ +"Temporary file error" = "Ideiglenes fájlhiba"; /* server test failure */ -"Test failed at step %@." = "A teszt sikertelen volt a(z) %@ lépésnél."; +"Test failed at step %@." = "A teszt a(z) %@ lépésnél sikertelen volt."; + +/* No comment provided by engineer. */ +"Test notifications" = "Értesítések tesztelése"; /* No comment provided by engineer. */ "Test server" = "Kiszolgáló tesztelése"; @@ -4701,34 +5151,34 @@ "Thank you for installing SimpleX Chat!" = "Köszönjük, hogy telepítette a SimpleX Chatet!"; /* No comment provided by engineer. */ -"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Köszönet a felhasználóknak – [hozzájárulás a Weblate-en keresztül](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Köszönet a felhasználóknak [a Weblate-en való közreműködésért](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; /* No comment provided by engineer. */ -"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak - hozzájárulás a Weblate-en!"; +"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak a Weblate-en való közreműködésért!"; /* No comment provided by engineer. */ -"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – beállítások megnyitása az engedélyezéshez."; +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy meghívási kéréseket kap – ezt a beállítások menüben engedélyezheti."; /* No comment provided by engineer. */ -"The app protects your privacy by using different operators in each conversation." = "Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőket használ."; +"The app protects your privacy by using different operators in each conversation." = "Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ."; /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról (kivéve .onion) történő letöltések megerősítését."; /* No comment provided by engineer. */ -"The attempt to change database passphrase was not completed." = "Az adatbázis jelmondatának megváltoztatására tett kísérlet nem fejeződött be."; +"The attempt to change database passphrase was not completed." = "Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be."; /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás."; /* No comment provided by engineer. */ -"The connection reached the limit of undelivered messages, your contact may be offline." = "A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van."; +"The connection reached the limit of undelivered messages, your contact may be offline." = "A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van."; /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "Az Ön által elfogadott kérelem vissza lesz vonva!"; /* No comment provided by engineer. */ -"The contact you shared this link with will NOT be able to connect!" = "Ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni!"; +"The contact you shared this link with will NOT be able to connect!" = "A partnere, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni!"; /* No comment provided by engineer. */ "The created archive is available via app Settings / Database / Old database archive." = "A létrehozott archívum a „Beállítások / Adatbázis / Régi adatbázis-archívum” menüben érhető el."; @@ -4737,37 +5187,31 @@ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet!"; /* No comment provided by engineer. */ -"The future of messaging" = "A privát üzenetküldés következő generációja"; +"The future of messaging" = "Az üzenetváltás jövője"; /* No comment provided by engineer. */ -"The hash of the previous message is different." = "Az előző üzenet hasító értéke különbözik."; +"The hash of the previous message is different." = "Az előző üzenet hasítóértéke különbözik."; /* No comment provided by engineer. */ -"The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő."; +"The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő."; /* No comment provided by engineer. */ -"The message will be deleted for all members." = "Az üzenet az összes tag számára törlésre kerül."; +"The message will be deleted for all members." = "Az üzenet az összes tag számára törölve lesz."; /* No comment provided by engineer. */ "The message will be marked as moderated for all members." = "Az üzenet az összes tag számára moderáltként lesz megjelölve."; /* No comment provided by engineer. */ -"The messages will be deleted for all members." = "Az üzenetek az összes tag számára törlésre kerülnek."; +"The messages will be deleted for all members." = "Az üzenetek az összes tag számára törölve lesznek."; /* No comment provided by engineer. */ "The messages will be marked as moderated for all members." = "Az üzenetek az összes tag számára moderáltként lesznek megjelölve."; /* No comment provided by engineer. */ -"The old database was not removed during the migration, it can be deleted." = "A régi adatbázis nem került eltávolításra az átköltöztetéskor, így törölhető."; +"The old database was not removed during the migration, it can be deleted." = "A régi adatbázis nem lett eltávolítva az átköltöztetéskor, ezért törölhető."; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "A profilja csak az ismerőseivel kerül megosztásra."; - -/* No comment provided by engineer. */ -"The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**."; - -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k)re is: **%@**."; +"The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**."; /* No comment provided by engineer. */ "The second preset operator in the app!" = "A második előre beállított üzemeltető az alkalmazásban!"; @@ -4779,16 +5223,16 @@ "The sender will NOT be notified" = "A feladó NEM fog értesítést kapni"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói **%@**."; +"The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegési profiljához tartozó új kapcsolatok kiszolgálói."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Az Ön jelenlegi **%@** nevű csevegőprofiljához tartozó új fájlok kiszolgálói."; +"The servers for new files of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegési profiljához tartozó új fájlok kiszolgálói."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "A beillesztett szöveg nem egy SimpleX-hivatkozás."; /* No comment provided by engineer. */ -"The uploaded database archive will be permanently removed from the servers." = "A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról."; +"The uploaded database archive will be permanently removed from the servers." = "A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról."; /* No comment provided by engineer. */ "Themes" = "Témák"; @@ -4797,52 +5241,61 @@ "These conditions will also apply for: **%@**." = "Ezek a feltételek lesznek elfogadva a következő számára is: **%@**."; /* No comment provided by engineer. */ -"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi (**%@**) profiljára vonatkoznak."; +"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi **%@** nevű csevegési profiljára vonatkoznak."; /* No comment provided by engineer. */ -"They can be overridden in contact and group settings." = "Ezek felülbírálhatók az ismerős- és csoportbeállításokban."; +"They can be overridden in contact and group settings." = "Ezek felülbírálhatók a partner- és csoportbeállításokban."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerül. Az alacsony felbontású képek viszont megmaradnak."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; + +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai visszafordíthatatlanul törlésre kerülnek."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek."; /* E2EE info chat item */ "This chat is protected by end-to-end encryption." = "Ez a csevegés végpontok közötti titkosítással védett."; /* E2EE info chat item */ -"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett."; +"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumbiztos titkosítással védett."; /* notification title */ -"this contact" = "ez az ismerős"; +"this contact" = "ez a partner"; /* No comment provided by engineer. */ "This device name" = "Ennek az eszköznek a neve"; /* No comment provided by engineer. */ -"This display name is invalid. Please choose another name." = "Ez a megjelenített név érvénytelen. Válasszon egy másik nevet."; +"This display name is invalid. Please choose another name." = "Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet."; /* No comment provided by engineer. */ -"This group has over %lld members, delivery receipts are not sent." = "Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem kerülnek elküldésre."; +"This group has over %lld members, delivery receipts are not sent." = "Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem lesznek elküldve."; /* No comment provided by engineer. */ "This group no longer exists." = "Ez a csoport már nem létezik."; /* No comment provided by engineer. */ -"This is your own one-time link!" = "Ez az Ön egyszer használható meghívó-hivatkozása!"; +"This is your own one-time link!" = "Ez a saját egyszer használható meghívója!"; /* No comment provided by engineer. */ -"This is your own SimpleX address!" = "Ez az Ön SimpleX-címe!"; +"This is your own SimpleX address!" = "Ez a saját SimpleX-címe!"; + +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől."; /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén."; /* No comment provided by engineer. */ -"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak a jelenlegi (**%@**) profiljában lévő üzenetekre vonatkozik."; +"This message was deleted or not received yet." = "Ez az üzenet törölve lett vagy még nem érkezett meg."; + +/* No comment provided by engineer. */ +"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegési profiljában lévő üzenetekre vonatkozik."; /* No comment provided by engineer. */ "Title" = "Cím"; @@ -4851,7 +5304,7 @@ "To ask any questions and to receive updates:" = "Bármilyen kérdés feltevéséhez és a frissítésekért:"; /* No comment provided by engineer. */ -"To connect, your contact can scan QR code or use the link in the app." = "A kapcsolódáshoz az ismerőse beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást."; +"To connect, your contact can scan QR code or use the link in the app." = "A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást."; /* No comment provided by engineer. */ "To hide unwanted messages." = "Kéretlen üzenetek elrejtése."; @@ -4860,7 +5313,7 @@ "To make a new connection" = "Új kapcsolat létrehozásához"; /* No comment provided by engineer. */ -"To protect against your link being replaced, you can compare contact security codes." = "A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat az ismerősével."; +"To protect against your link being replaced, you can compare contact security codes." = "A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével."; /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak."; @@ -4872,7 +5325,7 @@ "To protect your IP address, private routing uses your SMP servers to deliver messages." = "Az IP-cím védelmének érdekében a privát útválasztás az SMP-kiszolgálókat használja az üzenetek kézbesítéséhez."; /* No comment provided by engineer. */ -"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Az adatvédelem érdekében (a más csevegési platformokon megszokott felhasználó-azonosítók helyett) a SimpleX csak az üzenetek sorbaállításához használ azonosítókat, az összes ismerőséhez különbözőt."; +"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához."; /* No comment provided by engineer. */ "To receive" = "A fogadáshoz"; @@ -4887,7 +5340,7 @@ "To record voice message please grant permission to use Microphone." = "Hangüzenet rögzítéséhez adjon engedélyt a mikrofon használathoz."; /* No comment provided by engineer. */ -"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja megjelenítéséhez írja be a teljes jelszavát a keresőmezőbe a **Csevegési profilok** menüben."; +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben."; /* No comment provided by engineer. */ "To send" = "A küldéshez"; @@ -4899,13 +5352,16 @@ "To use the servers of **%@**, accept conditions of use." = "A(z) **%@** kiszolgálóinak használatához fogadja el a használati feltételeket."; /* No comment provided by engineer. */ -"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal."; +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; /* No comment provided by engineer. */ -"Toggle chat list:" = "Csevegőlista átváltása:"; +"Toggle chat list:" = "Csevegési lista átváltása:"; /* No comment provided by engineer. */ -"Toggle incognito when connecting." = "Inkognitómód használata kapcsolódáskor."; +"Toggle incognito when connecting." = "Inkognitóra váltás kapcsolódáskor."; + +/* token status */ +"Token status: %@." = "Token állapota: %@."; /* No comment provided by engineer. */ "Toolbar opacity" = "Eszköztár átlátszatlansága"; @@ -4914,16 +5370,16 @@ "Total" = "Összes kapcsolat"; /* No comment provided by engineer. */ -"Transport isolation" = "Átvitel-izoláció módja"; +"Transport isolation" = "Átvitelelkülönítés"; /* No comment provided by engineer. */ "Transport sessions" = "Munkamenetek átvitele"; /* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %@)."; +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@)."; /* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál."; +"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "Turkish interface" = "Török kezelőfelület"; @@ -4941,7 +5397,7 @@ "Unblock" = "Feloldás"; /* No comment provided by engineer. */ -"Unblock for all" = "Letiltás feloldása az összes tag számára"; +"Unblock for all" = "Feloldás"; /* No comment provided by engineer. */ "Unblock member" = "Tag feloldása"; @@ -4950,7 +5406,7 @@ "Unblock member for all?" = "Az összes tag számára feloldja a tag letiltását?"; /* No comment provided by engineer. */ -"Unblock member?" = "Tag feloldása?"; +"Unblock member?" = "Feloldja a tag letiltását?"; /* rcv group event chat item */ "unblocked %@" = "feloldotta %@ letiltását"; @@ -5001,13 +5457,13 @@ "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Hacsak nem az iOS hívási felületét használja, engedélyezze a Ne zavarjanak módot a megszakítások elkerülése érdekében."; /* No comment provided by engineer. */ -"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." = "Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e."; +"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." = "Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e."; /* No comment provided by engineer. */ "Unlink" = "Szétkapcsolás"; /* No comment provided by engineer. */ -"Unlink desktop?" = "Számítógép szétkapcsolása?"; +"Unlink desktop?" = "Leválasztja a számítógépet?"; /* No comment provided by engineer. */ "Unlock" = "Feloldás"; @@ -5015,10 +5471,7 @@ /* authentication reason */ "Unlock app" = "Alkalmazás feloldása"; -/* No comment provided by engineer. */ -"unmute" = "némítás megszüntetése"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Némítás megszüntetése"; /* No comment provided by engineer. */ @@ -5028,19 +5481,25 @@ "Unread" = "Olvasatlan"; /* No comment provided by engineer. */ -"Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára."; +"Unsupported connection link" = "Nem támogatott kapcsolattartási hivatkozás"; + +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára."; /* No comment provided by engineer. */ "Update" = "Frissítés"; /* No comment provided by engineer. */ -"Update database passphrase" = "Adatbázis-jelmondat megváltoztatása"; +"Update database passphrase" = "Az adatbázis jelmondatának módosítása"; /* No comment provided by engineer. */ -"Update network settings?" = "Hálózati beállítások megváltoztatása?"; +"Update network settings?" = "Módosítja a hálózati beállításokat?"; /* No comment provided by engineer. */ -"Update settings?" = "Beállítások frissítése?"; +"Update settings?" = "Frissíti a beállításokat?"; + +/* No comment provided by engineer. */ +"Updated conditions" = "Frissített feltételek"; /* rcv group event chat item */ "updated group profile" = "frissítette a csoport profilját"; @@ -5079,7 +5538,7 @@ "Use %@" = "%@ használata"; /* No comment provided by engineer. */ -"Use chat" = "Csevegés használata"; +"Use chat" = "SimpleX Chat használata"; /* No comment provided by engineer. */ "Use current profile" = "Jelenlegi profil használata"; @@ -5106,7 +5565,7 @@ "Use only local notifications?" = "Csak helyi értesítések használata?"; /* No comment provided by engineer. */ -"Use private routing with unknown servers when IP address is not protected." = "Használjon privát útválasztást ismeretlen kiszolgálókkal, ha az IP-cím nem védett."; +"Use private routing with unknown servers when IP address is not protected." = "Használjon privát útválasztást az ismeretlen kiszolgálókkal, ha az IP-cím nem védett."; /* No comment provided by engineer. */ "Use private routing with unknown servers." = "Használjon privát útválasztást ismeretlen kiszolgálókkal."; @@ -5117,11 +5576,20 @@ /* No comment provided by engineer. */ "Use servers" = "Kiszolgálók használata"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Rövid hivatkozások használata (béta)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "SimpleX Chat-kiszolgálók használata?"; /* No comment provided by engineer. */ -"Use SOCKS proxy" = "SOCKS proxy használata"; +"Use SOCKS proxy" = "SOCKS-proxy használata"; + +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "A következő TCP-port használata, amikor nincs port megadva: %@."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz."; /* No comment provided by engineer. */ "Use the app while in the call." = "Használja az alkalmazást hívás közben."; @@ -5130,7 +5598,10 @@ "Use the app with one hand." = "Használja az alkalmazást egy kézzel."; /* No comment provided by engineer. */ -"User selection" = "Felhasználó kiválasztása"; +"Use web port" = "Webport használata"; + +/* No comment provided by engineer. */ +"User selection" = "Felhasználó kijelölése"; /* No comment provided by engineer. */ "Username" = "Felhasználónév"; @@ -5169,19 +5640,19 @@ "Via browser" = "Böngészőn keresztül"; /* chat list item description */ -"via contact address link" = "kapcsolattartási cím-hivatkozáson keresztül"; +"via contact address link" = "a kapcsolattartási címhivatkozáson keresztül"; /* chat list item description */ "via group link" = "a csoporthivatkozáson keresztül"; /* chat list item description */ -"via one-time link" = "egyszer használható meghívó-hivatkozáson keresztül"; +"via one-time link" = "egy egyszer használható meghívón keresztül"; /* No comment provided by engineer. */ -"via relay" = "közvetítő-kiszolgálón keresztül"; +"via relay" = "egy továbbítókiszolgálón keresztül"; /* No comment provided by engineer. */ -"Via secure quantum resistant protocol." = "Biztonságos kvantumrezisztens-protokollon keresztül."; +"Via secure quantum resistant protocol." = "Biztonságos kvantumbiztos protokollon keresztül."; /* No comment provided by engineer. */ "video" = "videó"; @@ -5199,7 +5670,7 @@ "Video will be received when your contact is online, please wait or check later!" = "A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"Videos and files up to 1gb" = "Videók és fájlok 1Gb méretig"; +"Videos and files up to 1gb" = "Videók és fájlok legfeljebb 1GB méretig"; /* No comment provided by engineer. */ "View conditions" = "Feltételek megtekintése"; @@ -5223,13 +5694,13 @@ "Voice messages are prohibited in this chat." = "A hangüzenetek küldése le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Voice messages are prohibited." = "A hangüzenetek küldése le van tiltva ebben a csoportban."; +"Voice messages are prohibited." = "A hangüzenetek küldése le van tiltva."; /* No comment provided by engineer. */ "Voice messages not allowed" = "A hangüzenetek küldése le van tiltva"; /* No comment provided by engineer. */ -"Voice messages prohibited!" = "A hangüzenetek le vannak tilva!"; +"Voice messages prohibited!" = "A hangüzenetek le vannak tiltva!"; /* No comment provided by engineer. */ "waiting for answer…" = "várakozás a válaszra…"; @@ -5250,7 +5721,7 @@ "Waiting for video" = "Várakozás a videóra"; /* No comment provided by engineer. */ -"Wallpaper accent" = "Háttérkép kiemelés"; +"Wallpaper accent" = "Háttérkép kiemelőszíne"; /* No comment provided by engineer. */ "Wallpaper background" = "Háttérkép háttérszíne"; @@ -5259,7 +5730,7 @@ "wants to connect to you!" = "kapcsolatba akar lépni Önnel!"; /* No comment provided by engineer. */ -"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, továbbá üzenetkézbesítési hibákat okozhat"; +"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, mert üzenetkézbesítési hibákat okoz"; /* No comment provided by engineer. */ "Warning: you may lose some data!" = "Figyelmeztetés: néhány adat elveszhet!"; @@ -5271,7 +5742,7 @@ "weeks" = "hét"; /* No comment provided by engineer. */ -"Welcome %@!" = "Üdvözöllek %@!"; +"Welcome %@!" = "Üdvözöljük %@!"; /* No comment provided by engineer. */ "Welcome message" = "Üdvözlőüzenet"; @@ -5292,7 +5763,7 @@ "when IP hidden" = "ha az IP-cím rejtett"; /* No comment provided by engineer. */ -"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Amikor egynél több hálózati üzemeltető van engedélyezve, egyikük sem rendelkezik olyan metaadatokkal ahhoz, hogy felderítse, ki kommunikál kivel."; +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Amikor egynél több üzemeltető van engedélyezve, akkor egyik sem rendelkezik olyan metaadatokkal, amelyekből megtudható, hogy ki kivel kommunikál."; /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Inkognitóprofil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott."; @@ -5316,22 +5787,22 @@ "With reduced battery usage." = "Csökkentett akkumulátor-használattal."; /* No comment provided by engineer. */ -"Without Tor or VPN, your IP address will be visible to file servers." = "Tor vagy VPN nélkül az IP-címe látható lesz a fájlkiszolgálók számára."; +"Without Tor or VPN, your IP address will be visible to file servers." = "Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára."; /* alert message */ -"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az IP-címe látható lesz a következő XFTP-közvetítő-kiszolgálók számára: %@."; +"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára: %@."; /* No comment provided by engineer. */ -"Wrong database passphrase" = "Hibás adatbázis-jelmondat"; +"Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* snd error text */ -"Wrong key or unknown connection - most likely this connection is deleted." = "Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött."; +"Wrong key or unknown connection - most likely this connection is deleted." = "Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött."; /* file error text */ -"Wrong key or unknown file chunk address - most likely file is deleted." = "Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött."; +"Wrong key or unknown file chunk address - most likely file is deleted." = "Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött."; /* No comment provided by engineer. */ -"Wrong passphrase!" = "Hibás jelmondat!"; +"Wrong passphrase!" = "Érvénytelen jelmondat!"; /* No comment provided by engineer. */ "XFTP server" = "XFTP-kiszolgáló"; @@ -5348,23 +5819,26 @@ /* No comment provided by engineer. */ "You accepted connection" = "Kapcsolat létrehozása"; +/* snd group event chat item */ +"you accepted this member" = "Ön befogadta ezt a tagot"; + /* No comment provided by engineer. */ "You allow" = "Ön engedélyezi"; /* No comment provided by engineer. */ -"You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet."; +"You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet."; /* No comment provided by engineer. */ -"You are already connected to %@." = "Ön már kapcsolódva van ehhez: %@."; +"You are already connected to %@." = "Ön már kapcsolódott a következőhöz: %@."; /* No comment provided by engineer. */ "You are already connected with %@." = "Ön már kapcsolódva van vele: %@."; /* No comment provided by engineer. */ -"You are already connecting to %@." = "Már folyamatban van a kapcsolódás ehhez: %@."; +"You are already connecting to %@." = "A kapcsolódás már folyamatban van a következőhöz: %@."; /* No comment provided by engineer. */ -"You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül!"; +"You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívón keresztül!"; /* No comment provided by engineer. */ "You are already in group %@." = "Ön már a(z) %@ nevű csoport tagja."; @@ -5379,16 +5853,16 @@ "You are already joining the group via this link." = "A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül."; /* No comment provided by engineer. */ -"You are already joining the group!\nRepeat join request?" = "Csatlakozás folyamatban!\nCsatlakozáskérés megismétlése?"; +"You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a meghívási kérést?"; /* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál."; +"You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ -"you are invited to group" = "meghívást kapott a csoportba"; +"you are invited to group" = "Ön meghívást kapott a csoportba"; /* No comment provided by engineer. */ -"You are invited to group" = "Meghívást kapott a csoportba"; +"You are invited to group" = "Ön meghívást kapott a csoportba"; /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál."; @@ -5405,9 +5879,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben módosíthatja."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Az üzemeltetőket a „Hálózat és kiszolgálók” beállításaban konfigurálhatja."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja."; @@ -5418,40 +5889,40 @@ "You can enable later via Settings" = "Később engedélyezheti a „Beállításokban”"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Később engedélyezheti őket az alkalmazás „Adatvédelem és biztonság” menüjében."; +"You can enable them later via app Privacy & Security settings." = "Később engedélyezheti őket az „Adatvédelem és biztonság” menüben."; /* No comment provided by engineer. */ "You can give another try." = "Megpróbálhatja még egyszer."; /* No comment provided by engineer. */ -"You can hide or mute a user profile - swipe it to the right." = "Elrejtheti vagy lenémíthatja a felhasználó -profiljait - csúsztassa jobbra a profilt."; +"You can hide or mute a user profile - swipe it to the right." = "Elrejtheti vagy lenémíthatja a felhasználó -profiljait – csúsztassa jobbra a profilt."; /* No comment provided by engineer. */ -"You can make it visible to your SimpleX contacts via Settings." = "Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”."; +"You can make it visible to your SimpleX contacts via Settings." = "Láthatóvá teheti a SimpleXbeli partnerei számára a „Beállításokban”."; /* notification body */ "You can now chat with %@" = "Mostantól küldhet üzeneteket %@ számára"; /* No comment provided by engineer. */ -"You can send messages to %@ from Archived contacts." = "Az „Archivált ismerősökből” továbbra is küldhet üzeneteket neki: %@."; +"You can send messages to %@ from Archived contacts." = "Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %@."; /* No comment provided by engineer. */ -"You can set connection name, to remember who the link was shared with." = "Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást."; +"You can set connection name, to remember who the link was shared with." = "Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást."; /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "A lezárási képernyő értesítési előnézetét az „Értesítések” menüben állíthatja be."; /* No comment provided by engineer. */ -"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Megoszthat egy hivatkozást vagy QR-kódot - így bárki csatlakozhat a csoporthoz. Ha a csoport később törlésre kerül, akkor nem fogja elveszíteni annak tagjait."; +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoportot Ön később törli, akkor nem fogja elveszíteni annak tagjait."; /* No comment provided by engineer. */ -"You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a címet az ismerőseivel, hogy kapcsolatba léphessenek Önnel a(z) **%@** nevű profilján keresztül."; +"You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: **%@**."; /* No comment provided by engineer. */ "You can start chat via app Settings / Database or by restarting the app" = "A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el"; /* No comment provided by engineer. */ -"You can still view conversation with %@ in the list of chats." = "A(z) %@ nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában."; +"You can still view conversation with %@ in the list of chats." = "A(z) %@ nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában."; /* No comment provided by engineer. */ "You can turn on SimpleX Lock via Settings." = "A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be."; @@ -5460,22 +5931,25 @@ "You can use markdown to format messages:" = "Üzenetek formázása a szövegbe szúrt speciális karakterekkel:"; /* alert message */ -"You can view invitation link again in connection details." = "A meghívó-hivatkozást újra megtekintheti a kapcsolat részleteinél."; +"You can view invitation link again in connection details." = "A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél."; + +/* alert message */ +"You can view your reports in Chat with admins." = "A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben."; /* No comment provided by engineer. */ "You can't send messages!" = "Nem lehet üzeneteket küldeni!"; /* chat item text */ -"you changed address" = "cím megváltoztatva"; +"you changed address" = "Ön módosította a címet"; /* chat item text */ -"you changed address for %@" = "cím megváltoztatva nála: %@"; +"you changed address for %@" = "Ön módosította a címet %@ számára"; /* snd group event chat item */ -"you changed role for yourself to %@" = "saját szerepköre megváltozott erre: %@"; +"you changed role for yourself to %@" = "Ön a következőre módosította a saját szerepkörét: „%@”"; /* snd group event chat item */ -"you changed role of %@ to %@" = "Ön megváltoztatta %1$@ szerepkörét erre: %@"; +"you changed role of %@ to %@" = "Ön a következőre módosította %1$@ szerepkörét: „%2$@”"; /* No comment provided by engineer. */ "You could not be verified; please try again." = "Nem sikerült hitelesíteni; próbálja meg újra."; @@ -5484,22 +5958,22 @@ "You decide who can connect." = "Ön dönti el, hogy kivel beszélget."; /* No comment provided by engineer. */ -"You have already requested connection via this address!" = "Már küldött egy kapcsolatkérést ezen a címen keresztül!"; +"You have already requested connection via this address!" = "Már küldött egy meghívási kérést ezen a címen keresztül!"; /* No comment provided by engineer. */ -"You have already requested connection!\nRepeat connection request?" = "Már küldött egy kapcsolódási kérelmet!\nKapcsolatkérés megismétlése?"; +"You have already requested connection!\nRepeat connection request?" = "Ön már küldött egy meghívási kérést!\nMegismétli a meghívási kérést?"; /* No comment provided by engineer. */ -"You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra."; +"You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva."; /* No comment provided by engineer. */ -"You invited a contact" = "Meghívta egy ismerősét"; +"You invited a contact" = "Ön meghívta egy partnerét"; /* No comment provided by engineer. */ -"You joined this group" = "Csatlakozott ehhez a csoporthoz"; +"You joined this group" = "Ön csatlakozott ehhez a csoporthoz"; /* No comment provided by engineer. */ -"You joined this group. Connecting to inviting group member." = "Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz."; +"You joined this group. Connecting to inviting group member." = "Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz."; /* snd group event chat item */ "you left" = "Ön elhagyta a csoportot"; @@ -5511,13 +5985,13 @@ "You may save the exported archive." = "Az exportált archívumot elmentheti."; /* No comment provided by engineer. */ -"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől."; +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől."; /* No comment provided by engineer. */ -"You need to allow your contact to call to be able to call them." = "Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást."; +"You need to allow your contact to call to be able to call them." = "Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást."; /* No comment provided by engineer. */ -"You need to allow your contact to send voice messages to be able to send them." = "Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak."; +"You need to allow your contact to send voice messages to be able to send them." = "Engedélyeznie kell a hangüzenetek küldését a partnere számára, hogy hangüzeneteket küldhessenek egymásnak."; /* No comment provided by engineer. */ "You rejected group invitation" = "Csoportmeghívó elutasítva"; @@ -5529,10 +6003,13 @@ "You sent group invitation" = "Csoportmeghívó elküldve"; /* chat list item description */ -"you shared one-time link" = "Ön egy egyszer használható meghívó-hivatkozást osztott meg"; +"you shared one-time link" = "Ön egy egyszer használható meghívót osztott meg"; /* chat list item description */ -"you shared one-time link incognito" = "Ön egy egyszer használható meghívó-hivatkozást osztott meg inkognitóban"; +"you shared one-time link incognito" = "Ön egy egyszer használható meghívót osztott meg inkognitóban"; + +/* token info */ +"You should receive notifications." = "Ön megkapja az értesítéseket."; /* snd group event chat item */ "you unblocked %@" = "Ön feloldotta %@ letiltását"; @@ -5544,13 +6021,13 @@ "You will be connected when group link host's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a csoporthivatkozás tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később!"; +"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a meghívási kérése el lesz fogadva, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később!"; +"You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Az alkalmazás indításakor, vagy 30 másodpercnyi háttérben töltött idő után az alkalmazáshoz visszatérve hitelesítés szükséges."; +"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség."; /* No comment provided by engineer. */ "You will connect to all group members." = "Kapcsolódni fog a csoport összes tagjához."; @@ -5565,16 +6042,16 @@ "You will stop receiving messages from this group. Chat history will be preserved." = "Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak."; /* No comment provided by engineer. */ -"You won't lose your contacts if you later delete your address." = "Nem veszíti el az ismerőseit, ha később törli a címét."; +"You won't lose your contacts if you later delete your address." = "Nem veszíti el a partnereit, ha később törli a címét."; /* No comment provided by engineer. */ "you: " = "Ön: "; /* No comment provided by engineer. */ -"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban"; +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban"; /* No comment provided by engineer. */ -"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Inkognitóprofilt használ ehhez a csoporthoz - fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva"; +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Inkognitóprofilt használ ehhez a csoporthoz – fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva"; /* No comment provided by engineer. */ "Your calls" = "Hívások"; @@ -5586,7 +6063,7 @@ "Your chat database is not encrypted - set passphrase to encrypt it." = "A csevegési adatbázis nincs titkosítva – adjon meg egy jelmondatot a titkosításhoz."; /* alert title */ -"Your chat preferences" = "Csevegési beállítások"; +"Your chat preferences" = "Az Ön csevegési beállításai"; /* No comment provided by engineer. */ "Your chat profiles" = "Csevegési profilok"; @@ -5595,19 +6072,19 @@ "Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." = "A kapcsolata át lett helyezve ide: %@, de egy váratlan hiba történt a profilra való átirányításkor."; /* No comment provided by engineer. */ -"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Az ismerőse olyan fájlt küldött, amely meghaladja a jelenleg támogatott maximális méretet (%@)."; +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött."; /* No comment provided by engineer. */ -"Your contacts can allow full message deletion." = "Az ismerősei engedélyezhetik a teljes üzenet törlést."; +"Your contacts can allow full message deletion." = "A partnerei engedélyezhetik a teljes üzenet törlését."; /* No comment provided by engineer. */ -"Your contacts will remain connected." = "Az ismerősei továbbra is kapcsolódva maradnak."; +"Your contacts will remain connected." = "A partnerei továbbra is kapcsolódva maradnak."; /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "A hitelesítőadatai titkosítatlanul is elküldhetők."; /* No comment provided by engineer. */ -"Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRLŐDNI FOG, és a HELYÉRE az importált adatbázis kerül."; +"Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra."; /* No comment provided by engineer. */ "Your current profile" = "Jelenlegi profil"; @@ -5625,16 +6102,16 @@ "Your profile" = "Profil"; /* No comment provided by engineer. */ -"Your profile **%@** will be shared." = "A(z) **%@** nevű profilja megosztásra fog kerülni."; +"Your profile **%@** will be shared." = "A(z) **%@** nevű profilja meg lesz osztva."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "A profilja az eszközön van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját."; +"Your profile is stored on your device and only shared with your contacts." = "A profilja az eszközén van tárolva és csak a partnereivel van megosztva."; + +/* No comment provided by engineer. */ +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját."; /* alert message */ -"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "A profilja megváltozott. Ha elmenti, a frissített profil elküldésre kerül az összes ismerősének."; - -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "A profilja, az ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra."; +"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "A profilja módosult. Ha elmenti, a profilfrissítés el lesz küldve a partnerei számára."; /* No comment provided by engineer. */ "Your random profile" = "Véletlenszerű profil"; @@ -5643,7 +6120,7 @@ "Your server address" = "Saját SMP-kiszolgálójának címe"; /* No comment provided by engineer. */ -"Your servers" = "Az Ön kiszolgálói"; +"Your servers" = "Saját kiszolgálók"; /* No comment provided by engineer. */ "Your settings" = "Beállítások"; @@ -5651,6 +6128,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Profil SimpleX-címe"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Saját SMP-kiszolgálók"; - diff --git a/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings index 434f906b4e..f389e41458 100644 --- a/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings @@ -8,7 +8,7 @@ "NSFaceIDUsageDescription" = "A SimpleX Face ID-t használ a helyi hitelesítéshez"; /* Privacy - Local Network Usage Description */ -"NSLocalNetworkUsageDescription" = "A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegőprofil használatát számítógépen keresztül ugyanazon a hálózaton."; +"NSLocalNetworkUsageDescription" = "A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegési profil használatát számítógépen keresztül ugyanazon a hálózaton."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "A SimpleXnek mikrofon-hozzáférésre van szüksége hang- és videohívásokhoz, valamint hangüzenetek rögzítéséhez."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 7c3a7e05de..f36b35efc2 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (può essere copiato)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messaggi vocali fino a 5 minuti.\n- tempo di scomparsa personalizzato.\n- cronologia delle modifiche."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 colorato!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nuovo)"; /* No comment provided by engineer. */ "(this device v%@)" = "(questo dispositivo v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuisci](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -199,6 +178,9 @@ /* time interval */ "%d sec" = "%d sec"; +/* delete after time */ +"%d seconds(s)" = "%d secondo/i"; + /* integrity error chat item */ "%d skipped message(s)" = "%d messaggio/i saltato/i"; @@ -241,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nuove lingue dell'interfaccia"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld secondo/i"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld secondi"; @@ -289,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 giorno"; /* time interval */ @@ -298,12 +278,17 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuto"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 mese"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 settimana"; +/* delete after time */ +"1 year" = "1 anno"; + /* No comment provided by engineer. */ "1-time link" = "Link una tantum"; @@ -343,6 +328,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Interrompere il cambio di indirizzo?"; +/* No comment provided by engineer. */ +"About operators" = "Info sugli operatori"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "Riguardo SimpleX Chat"; @@ -353,10 +341,16 @@ "Accent" = "Principale"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Accetta"; +/* alert action */ +"Accept as member" = "Accetta come membro"; + +/* alert action */ +"Accept as observer" = "Accetta come osservatore"; + /* No comment provided by engineer. */ "Accept conditions" = "Accetta le condizioni"; @@ -367,21 +361,36 @@ "Accept contact request from %@?" = "Accettare la richiesta di contatto da %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Accetta in incognito"; +/* alert title */ +"Accept member" = "Accetta membro"; + +/* rcv group event chat item */ +"accepted %@" = "%@ accettato"; + /* call status */ "accepted call" = "chiamata accettata"; /* No comment provided by engineer. */ "Accepted conditions" = "Condizioni accettate"; +/* chat list item title */ +"accepted invitation" = "invito accettato"; + +/* rcv group event chat item */ +"accepted you" = "ti ha accettato/a"; + /* No comment provided by engineer. */ "Acknowledged" = "Riconosciuto"; /* No comment provided by engineer. */ "Acknowledgement errors" = "Errori di riconoscimento"; +/* token status text */ +"Active" = "Attivo"; + /* No comment provided by engineer. */ "Active connections" = "Connessioni attive"; @@ -391,6 +400,9 @@ /* No comment provided by engineer. */ "Add friends" = "Aggiungi amici"; +/* No comment provided by engineer. */ +"Add list" = "Aggiungi elenco"; + /* No comment provided by engineer. */ "Add profile" = "Aggiungi profilo"; @@ -406,6 +418,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Aggiungi ad un altro dispositivo"; +/* No comment provided by engineer. */ +"Add to list" = "Aggiungi ad un elenco"; + /* No comment provided by engineer. */ "Add welcome message" = "Aggiungi messaggio di benvenuto"; @@ -463,12 +478,21 @@ /* chat item text */ "agreeing encryption…" = "concordando la crittografia…"; +/* member criteria value */ +"all" = "tutti"; + +/* No comment provided by engineer. */ +"All" = "Tutte"; + /* No comment provided by engineer. */ "All app data is deleted." = "Tutti i dati dell'app vengono eliminati."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Tutte le chat e i messaggi verranno eliminati. Non è reversibile!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Tutte le chat verranno rimosse dall'elenco %@ e l'elenco eliminato."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Tutti i dati vengono cancellati quando inserito."; @@ -496,6 +520,12 @@ /* profile dropdown */ "All profiles" = "Tutti gli profili"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Tutte le segnalazioni verranno archiviate per te."; + +/* No comment provided by engineer. */ +"All servers" = "Tutti i server"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Tutti i tuoi contatti resteranno connessi."; @@ -541,6 +571,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Consenti di segnalare messaggi ai moderatori."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Consenti l'invio di file e contenuti multimediali."; @@ -595,6 +628,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "e altri %lld eventi"; +/* report reason */ +"Another reason" = "Altro motivo"; + /* No comment provided by engineer. */ "Answer call" = "Rispondi alla chiamata"; @@ -610,6 +646,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "L'app cripta i nuovi file locali (eccetto i video)."; +/* No comment provided by engineer. */ +"App group:" = "Gruppo app:"; + /* No comment provided by engineer. */ "App icon" = "Icona app"; @@ -637,15 +676,36 @@ /* No comment provided by engineer. */ "Apply to" = "Applica a"; +/* No comment provided by engineer. */ +"Archive" = "Archivia"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiviare %lld segnalazioni?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archiviare tutte le segnalazioni?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivia e carica"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archivia contatti per chattare più tardi."; +/* No comment provided by engineer. */ +"Archive report" = "Archivia la segnalazione"; + +/* No comment provided by engineer. */ +"Archive report?" = "Archiviare la segnalazione?"; + +/* swipe action */ +"Archive reports" = "Archivia segnalazioni"; + /* No comment provided by engineer. */ "Archived contacts" = "Contatti archiviati"; +/* No comment provided by engineer. */ +"archived report" = "segnalazione archiviata"; + /* No comment provided by engineer. */ "Archiving database" = "Archiviazione del database"; @@ -724,6 +784,9 @@ /* No comment provided by engineer. */ "Better groups" = "Gruppi migliorati"; +/* No comment provided by engineer. */ +"Better groups performance" = "Prestazioni dei gruppi migliorate"; + /* No comment provided by engineer. */ "Better message dates." = "Date dei messaggi migliorate."; @@ -736,6 +799,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Notifiche migliorate"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Privacy e sicurezza migliori"; + /* No comment provided by engineer. */ "Better security ✅" = "Sicurezza migliorata ✅"; @@ -769,7 +835,8 @@ /* rcv group event chat item */ "blocked %@" = "ha bloccato %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "bloccato dall'amministratore"; /* No comment provided by engineer. */ @@ -808,9 +875,15 @@ /* No comment provided by engineer. */ "Business chats" = "Chat di lavoro"; +/* No comment provided by engineer. */ +"Businesses" = "Lavorative"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Usando SimpleX Chat accetti di:\n- inviare solo contenuto legale nei gruppi pubblici.\n- rispettare gli altri utenti - niente spam."; + /* No comment provided by engineer. */ "call" = "chiama"; @@ -850,8 +923,11 @@ /* No comment provided by engineer. */ "Can't message member" = "Impossibile inviare un messaggio al membro"; +/* No comment provided by engineer. */ +"can't send messages" = "impossibile inviare messaggi"; + /* alert action - alert button */ +alert button */ "Cancel" = "Annulla"; /* No comment provided by engineer. */ @@ -878,6 +954,9 @@ /* No comment provided by engineer. */ "Change" = "Cambia"; +/* alert title */ +"Change automatic message deletion?" = "Cambiare l'eliminazione automatica dei messaggi?"; + /* authentication reason */ "Change chat profiles" = "Modifica profili utente"; @@ -906,7 +985,7 @@ "Change self-destruct mode" = "Cambia modalità di autodistruzione"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Cambia codice di autodistruzione"; /* chat item text */ @@ -984,9 +1063,18 @@ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "La chat verrà eliminata solo per te, non è reversibile!"; +/* chat toolbar */ +"Chat with admins" = "Chat con amministratori"; + +/* No comment provided by engineer. */ +"Chat with member" = "Chatta con il membro"; + /* No comment provided by engineer. */ "Chats" = "Chat"; +/* No comment provided by engineer. */ +"Chats with members" = "Chat con membri"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Controlla i messaggi ogni 20 min."; @@ -1026,6 +1114,12 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Svuotare la conversazione?"; +/* No comment provided by engineer. */ +"Clear group?" = "Svuotare il gruppo?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Svuotare o eliminare il gruppo?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Svuotare le note private?"; @@ -1041,6 +1135,9 @@ /* No comment provided by engineer. */ "colored" = "colorato"; +/* report reason */ +"Community guidelines violation" = "Violazione delle linee guida della comunità"; + /* server test step */ "Compare file" = "Confronta file"; @@ -1065,12 +1162,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Condizioni d'uso"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Le condizioni verranno accettate per gli operatori attivati dopo 30 giorni."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Le condizioni verranno accettate per gli operatori: **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Le condizioni verranno accettate per gli operatori: **%@**."; @@ -1083,6 +1174,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "Configura server ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Configura gli operatori dei server"; + /* No comment provided by engineer. */ "Confirm" = "Conferma"; @@ -1113,6 +1207,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Conferma caricamento"; +/* token status text */ +"Confirmed" = "Confermato"; + /* server test step */ "Connect" = "Connetti"; @@ -1212,6 +1309,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Stato della connessione e dei server."; +/* No comment provided by engineer. */ +"Connection blocked" = "Connessione bloccata"; + /* No comment provided by engineer. */ "Connection error" = "Errore di connessione"; @@ -1221,12 +1321,21 @@ /* chat list item title (it should not be shown */ "connection established" = "connessione stabilita"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "La connessione è bloccata dall'operatore del server:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Connessione non pronta."; + /* No comment provided by engineer. */ "Connection notifications" = "Notifiche di connessione"; /* No comment provided by engineer. */ "Connection request sent!" = "Richiesta di connessione inviata!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "La connessione richiede la rinegoziazione della crittografia."; + /* No comment provided by engineer. */ "Connection security" = "Sicurezza della connessione"; @@ -1254,9 +1363,15 @@ /* No comment provided by engineer. */ "Contact already exists" = "Il contatto esiste già"; +/* No comment provided by engineer. */ +"contact deleted" = "contatto eliminato"; + /* No comment provided by engineer. */ "Contact deleted!" = "Contatto eliminato!"; +/* No comment provided by engineer. */ +"contact disabled" = "contatto disattivato"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "il contatto ha la crittografia e2e"; @@ -1275,6 +1390,9 @@ /* No comment provided by engineer. */ "Contact name" = "Nome del contatto"; +/* No comment provided by engineer. */ +"contact not ready" = "contatto non pronto"; + /* No comment provided by engineer. */ "Contact preferences" = "Preferenze del contatto"; @@ -1287,6 +1405,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "I contatti possono contrassegnare i messaggi per l'eliminazione; potrai vederli."; +/* blocking reason */ +"Content violates conditions of use" = "Il contenuto viola le condizioni di utilizzo"; + /* No comment provided by engineer. */ "Continue" = "Continua"; @@ -1329,6 +1450,9 @@ /* No comment provided by engineer. */ "Create link" = "Crea link"; +/* No comment provided by engineer. */ +"Create list" = "Crea elenco"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Crea un nuovo profilo nell'[app desktop](https://simplex.chat/downloads/). 💻"; @@ -1470,7 +1594,8 @@ /* No comment provided by engineer. */ "decryption errors" = "errori di decifrazione"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "predefinito (%@)"; /* No comment provided by engineer. */ @@ -1480,8 +1605,7 @@ "default (yes)" = "predefinito (sì)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Elimina"; /* No comment provided by engineer. */ @@ -1508,12 +1632,18 @@ /* No comment provided by engineer. */ "Delete chat" = "Elimina chat"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Elimina i messaggi di chat dal tuo dispositivo."; + /* No comment provided by engineer. */ "Delete chat profile" = "Elimina il profilo di chat"; /* No comment provided by engineer. */ "Delete chat profile?" = "Eliminare il profilo di chat?"; +/* alert title */ +"Delete chat with member?" = "Eliminare la chat con il membro?"; + /* No comment provided by engineer. */ "Delete chat?" = "Eliminare la chat?"; @@ -1562,13 +1692,16 @@ /* No comment provided by engineer. */ "Delete link?" = "Eliminare il link?"; +/* alert title */ +"Delete list?" = "Eliminare l'elenco?"; + /* No comment provided by engineer. */ "Delete member message?" = "Eliminare il messaggio del membro?"; /* No comment provided by engineer. */ "Delete message?" = "Eliminare il messaggio?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Elimina messaggi"; /* No comment provided by engineer. */ @@ -1592,6 +1725,9 @@ /* server test step */ "Delete queue" = "Elimina coda"; +/* No comment provided by engineer. */ +"Delete report" = "Elimina la segnalazione"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Elimina fino a 20 messaggi contemporaneamente."; @@ -1700,6 +1836,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Disattiva (mantieni sostituzioni)"; +/* alert title */ +"Disable automatic message deletion?" = "Disattivare l'eliminazione automatica dei messaggi?"; + +/* alert button */ +"Disable delete messages" = "Disattiva eliminazione messaggi"; + /* No comment provided by engineer. */ "Disable for all" = "Disattiva per tutti"; @@ -1760,6 +1902,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NON usare SimpleX per chiamate di emergenza."; +/* No comment provided by engineer. */ +"Documents:" = "Documenti:"; + /* No comment provided by engineer. */ "Don't create address" = "Non creare un indirizzo"; @@ -1767,13 +1912,19 @@ "Don't enable" = "Non attivare"; /* No comment provided by engineer. */ +"Don't miss important messages." = "Non perdere messaggi importanti."; + +/* alert action */ "Don't show again" = "Non mostrare più"; +/* No comment provided by engineer. */ +"Done" = "Fatto"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Esegui downgrade e apri chat"; /* alert button - chat item action */ +chat item action */ "Download" = "Scarica"; /* No comment provided by engineer. */ @@ -1830,14 +1981,14 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Attiva (mantieni sostituzioni)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Attivare l'eliminazione automatica dei messaggi?"; /* No comment provided by engineer. */ "Enable camera access" = "Attiva l'accesso alla fotocamera"; /* No comment provided by engineer. */ -"Enable Flux" = "Attiva Flux"; +"Enable Flux in Network & servers settings for better metadata privacy." = "Attiva Flux nelle impostazioni \"Rete e server\" per una migliore privacy dei metadati."; /* No comment provided by engineer. */ "Enable for all" = "Attiva per tutti"; @@ -1950,6 +2101,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "richiesta rinegoziazione della crittografia per %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Rinegoziazione della crittografia in corso."; + /* No comment provided by engineer. */ "ended" = "terminata"; @@ -2004,6 +2158,9 @@ /* No comment provided by engineer. */ "Error accepting contact request" = "Errore nell'accettazione della richiesta di contatto"; +/* alert title */ +"Error accepting member" = "Errore di accettazione del membro"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Errore di aggiunta membro/i"; @@ -2025,6 +2182,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Errore nel passaggio a incognito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Errore di controllo dello stato del token"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Errore di connessione al server di inoltro %@. Riprova più tardi."; @@ -2037,6 +2197,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Errore nella creazione del link del gruppo"; +/* alert title */ +"Error creating list" = "Errore nella creazione dell'elenco"; + /* No comment provided by engineer. */ "Error creating member contact" = "Errore di creazione del contatto"; @@ -2046,12 +2209,18 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Errore nella creazione del profilo!"; +/* No comment provided by engineer. */ +"Error creating report" = "Errore nella creazione del resoconto"; + /* No comment provided by engineer. */ "Error decrypting file" = "Errore decifrando il file"; /* No comment provided by engineer. */ "Error deleting chat database" = "Errore nell'eliminazione del database della chat"; +/* alert title */ +"Error deleting chat with member" = "Errore di eliminazione della chat con il membro"; + /* No comment provided by engineer. */ "Error deleting chat!" = "Errore nell'eliminazione della chat!"; @@ -2112,12 +2281,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Errore di riconnessione ai server"; -/* No comment provided by engineer. */ +/* alert title */ +"Error registering for notifications" = "Errore di registrazione per le notifiche"; + +/* alert title */ "Error removing member" = "Errore nella rimozione del membro"; +/* alert title */ +"Error reordering lists" = "Errore riordinando gli elenchi"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Errore di azzeramento statistiche"; +/* alert title */ +"Error saving chat list" = "Errore nel salvataggio dell'elenco di chat"; + /* No comment provided by engineer. */ "Error saving group profile" = "Errore nel salvataggio del profilo del gruppo"; @@ -2169,6 +2347,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Errore nella sincronizzazione della connessione"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Errore provando la connessione al server"; + /* No comment provided by engineer. */ "Error updating group link" = "Errore nell'aggiornamento del link del gruppo"; @@ -2193,7 +2374,9 @@ /* No comment provided by engineer. */ "Error: " = "Errore: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Errore: %@"; /* No comment provided by engineer. */ @@ -2211,9 +2394,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Anche quando disattivato nella conversazione."; -/* No comment provided by engineer. */ -"event happened" = "evento accaduto"; - /* No comment provided by engineer. */ "Exit without saving" = "Esci senza salvare"; @@ -2223,6 +2403,9 @@ /* No comment provided by engineer. */ "expired" = "scaduto"; +/* token status text */ +"Expired" = "Scaduto"; + /* No comment provided by engineer. */ "Export database" = "Esporta database"; @@ -2247,18 +2430,30 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Veloce e senza aspettare che il mittente sia in linea!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Eliminazione dei gruppi più veloce."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Ingresso più veloce e messaggi più affidabili."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Invio dei messaggi più veloce."; + /* swipe action */ "Favorite" = "Preferito"; /* No comment provided by engineer. */ +"Favorites" = "Preferite"; + +/* file error alert title */ "File error" = "Errore del file"; /* alert message */ "File errors:\n%@" = "Errori di file:\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Il file è bloccato dall'operatore del server:\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "File non trovato - probabilmente è stato eliminato o annullato."; @@ -2335,7 +2530,7 @@ "Fix not supported by group member" = "Correzione non supportata dal membro del gruppo"; /* No comment provided by engineer. */ -"for better metadata privacy." = "per una migliore privacy dei metadati."; +"For all moderators" = "Per tutti i moderatori"; /* servers error */ "For chat profile %@:" = "Per il profilo di chat %@:"; @@ -2346,6 +2541,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Ad esempio, se il tuo contatto riceve messaggi tramite un server di SimpleX Chat, la tua app li consegnerà tramite un server Flux."; +/* No comment provided by engineer. */ +"For me" = "Per me"; + /* No comment provided by engineer. */ "For private routing" = "Per l'instradamento privato"; @@ -2418,6 +2616,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Ulteriore riduzione del consumo della batteria"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Ricevi una notifica quando menzionato."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF e adesivi"; @@ -2457,6 +2658,9 @@ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "L'invito al gruppo non è più valido, è stato rimosso dal mittente."; +/* No comment provided by engineer. */ +"group is deleted" = "il gruppo è eliminato"; + /* No comment provided by engineer. */ "Group link" = "Link del gruppo"; @@ -2490,9 +2694,15 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Il gruppo verrà eliminato per te. Non è reversibile!"; +/* No comment provided by engineer. */ +"Groups" = "Gruppi"; + /* No comment provided by engineer. */ "Help" = "Aiuto"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Aiuta gli amministratori a moderare i loro gruppi."; + /* No comment provided by engineer. */ "Hidden" = "Nascosta"; @@ -2529,6 +2739,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Come aiuta la privacy"; +/* alert button */ +"How it works" = "Come funziona"; + /* No comment provided by engineer. */ "How SimpleX works" = "Come funziona SimpleX"; @@ -2616,6 +2829,12 @@ /* No comment provided by engineer. */ "inactive" = "inattivo"; +/* report reason */ +"Inappropriate content" = "Contenuto inappropriato"; + +/* report reason */ +"Inappropriate profile" = "Profilo inappropriato"; + /* No comment provided by engineer. */ "Incognito" = "Incognito"; @@ -2682,6 +2901,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Colori dell'interfaccia"; +/* token status text */ +"Invalid" = "Non valido"; + +/* token status text */ +"Invalid (bad token)" = "Non valido (token corrotto)"; + +/* token status text */ +"Invalid (expired)" = "Non valido (scaduto)"; + +/* token status text */ +"Invalid (unregistered)" = "Non valido (non registrato)"; + +/* token status text */ +"Invalid (wrong topic)" = "Non valido (argomento sbagliato)"; + /* invalid chat data */ "invalid chat" = "chat non valida"; @@ -2883,6 +3117,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Desktop collegati"; +/* swipe action */ +"List" = "Elenco"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Il nome dell'elenco e l'emoji dovrebbero essere diversi per tutte le liste."; + +/* No comment provided by engineer. */ +"List name..." = "Nome elenco..."; + /* No comment provided by engineer. */ "LIVE" = "IN DIRETTA"; @@ -2944,14 +3187,23 @@ "Member" = "Membro"; /* profile update event chat item */ -"member %@ changed to %@" = "membro %1$@ cambiato in %2$@"; +"member %@ changed to %@" = "il membro %1$@ è diventato %2$@"; + +/* No comment provided by engineer. */ +"Member admission" = "Ammissione del membro"; /* rcv group event chat item */ "member connected" = "si è connesso/a"; +/* No comment provided by engineer. */ +"member has old version" = "il membro ha una versione vecchia"; + /* item status text */ "Member inactive" = "Membro inattivo"; +/* chat feature */ +"Member reports" = "Segnalazioni dei membri"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "Il ruolo del membro verrà cambiato in \"%@\". Verranno notificati tutti i membri della chat."; @@ -2967,12 +3219,18 @@ /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Il membro verrà rimosso dal gruppo, non è reversibile!"; +/* alert message */ +"Member will join the group, accept member?" = "Il membro entrerà nel gruppo, accettarlo?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "I membri del gruppo possono aggiungere reazioni ai messaggi."; /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "I membri del gruppo possono eliminare irreversibilmente i messaggi inviati. (24 ore)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "I membri possono segnalare messaggi ai moderatori."; + /* No comment provided by engineer. */ "Members can send direct messages." = "I membri del gruppo possono inviare messaggi diretti."; @@ -2988,6 +3246,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "I membri del gruppo possono inviare messaggi vocali."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Menziona i membri 👋"; + /* No comment provided by engineer. */ "Menus" = "Menu"; @@ -3060,6 +3321,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "I messaggi da %@ verranno mostrati!"; +/* alert message */ +"Messages in this chat will never be deleted." = "I messaggi in questa chat non verranno mai eliminati."; + /* No comment provided by engineer. */ "Messages received" = "Messaggi ricevuti"; @@ -3132,9 +3396,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "moderato da %@"; +/* member role */ +"moderator" = "moderatore"; + /* time unit */ "months" = "mesi"; +/* swipe action */ +"More" = "Altro"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Altri miglioramenti sono in arrivo!"; @@ -3150,12 +3420,12 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Profili di chat multipli"; -/* No comment provided by engineer. */ -"mute" = "silenzia"; - -/* swipe action */ +/* notification label action */ "Mute" = "Silenzia"; +/* notification label action */ +"Mute all" = "Silenzia tutto"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Silenzioso quando inattivo!"; @@ -3186,9 +3456,12 @@ /* No comment provided by engineer. */ "Network status" = "Stato della rete"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "mai"; +/* token status text */ +"New" = "Nuovo"; + /* No comment provided by engineer. */ "New chat" = "Nuova chat"; @@ -3219,6 +3492,9 @@ /* No comment provided by engineer. */ "New member role" = "Nuovo ruolo del membro"; +/* rcv group event chat item */ +"New member wants to join the group." = "Un nuovo membro vuole entrare nel gruppo."; + /* notification */ "new message" = "messaggio nuovo"; @@ -3249,6 +3525,18 @@ /* Authentication unavailable */ "No app password" = "Nessuna password dell'app"; +/* No comment provided by engineer. */ +"No chats" = "Nessuna chat"; + +/* No comment provided by engineer. */ +"No chats found" = "Nessuna chat trovata"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Nessuna chat nell'elenco %@"; + +/* No comment provided by engineer. */ +"No chats with members" = "Nessuna chat con membri"; + /* No comment provided by engineer. */ "No contacts selected" = "Nessun contatto selezionato"; @@ -3282,6 +3570,9 @@ /* servers error */ "No media & file servers." = "Nessun server di multimediali e file."; +/* No comment provided by engineer. */ +"No message" = "Nessun messaggio"; + /* servers error */ "No message servers." = "Nessun server dei messaggi."; @@ -3318,12 +3609,24 @@ /* copied message info in history */ "no text" = "nessun testo"; +/* alert title */ +"No token!" = "Nessun token!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Nessuna chat non letta"; + /* No comment provided by engineer. */ "No user identifiers." = "Nessun identificatore utente."; /* No comment provided by engineer. */ "Not compatible!" = "Non compatibile!"; +/* No comment provided by engineer. */ +"not synchronized" = "non sincronizzato"; + +/* No comment provided by engineer. */ +"Notes" = "Note"; + /* No comment provided by engineer. */ "Nothing selected" = "Nessuna selezione"; @@ -3336,9 +3639,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Le notifiche sono disattivate!"; +/* alert title */ +"Notifications error" = "Errore delle notifiche"; + /* No comment provided by engineer. */ "Notifications privacy" = "Privacy delle notifiche"; +/* alert title */ +"Notifications status" = "Stato delle notifiche"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Ora gli amministratori possono:\n- eliminare i messaggi dei membri.\n- disattivare i membri (ruolo \"osservatore\")"; @@ -3346,8 +3655,9 @@ "observer" = "osservatore"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "off"; /* blur media */ @@ -3359,7 +3669,8 @@ /* feature offered item */ "offered %@: %@" = "offerto %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3401,6 +3712,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Solo i proprietari del gruppo possono attivare i messaggi vocali."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Solo il mittente e i moderatori lo vedono"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Solo tu e i moderatori lo vedete"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Solo tu puoi aggiungere reazioni ai messaggi."; @@ -3431,7 +3748,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Solo il tuo contatto può inviare messaggi vocali."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Apri"; /* No comment provided by engineer. */ @@ -3449,6 +3766,9 @@ /* No comment provided by engineer. */ "Open group" = "Apri gruppo"; +/* alert title */ +"Open link?" = "Aprire il link?"; + /* authentication reason */ "Open migration to another device" = "Apri migrazione ad un altro dispositivo"; @@ -3482,6 +3802,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "O per condividere in modo privato"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organizza le chat in elenchi"; + /* No comment provided by engineer. */ "other" = "altro"; @@ -3539,9 +3862,18 @@ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "in attesa"; + /* No comment provided by engineer. */ "Pending" = "In attesa"; +/* No comment provided by engineer. */ +"pending approval" = "in attesa di approvazione"; + +/* No comment provided by engineer. */ +"pending review" = "in attesa di revisione"; + /* No comment provided by engineer. */ "Periodic" = "Periodicamente"; @@ -3608,6 +3940,18 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Conserva la password in modo sicuro, NON potrai cambiarla se la perdi."; +/* token info */ +"Please try to disable and re-enable notfications." = "Prova a disattivare e riattivare le notifiche."; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Attendi che i moderatori del gruppo revisionino la tua richiesta di entrare nel gruppo."; + +/* token info */ +"Please wait for token activation to complete." = "Attendi il completamento dell'attivazione del token."; + +/* token info */ +"Please wait for token to be registered." = "Attendi la registrazione del token."; + /* No comment provided by engineer. */ "Polish interface" = "Interfaccia polacca"; @@ -3638,12 +3982,21 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy per i tuoi clienti."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Informativa sulla privacy e condizioni d'uso."; + /* No comment provided by engineer. */ "Privacy redefined" = "Privacy ridefinita"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server."; + /* No comment provided by engineer. */ "Private filenames" = "Nomi di file privati"; +/* No comment provided by engineer. */ +"Private media file names." = "Nomi privati dei file multimediali."; + /* No comment provided by engineer. */ "Private message routing" = "Instradamento privato dei messaggi"; @@ -3689,6 +4042,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Proibisci le reazioni ai messaggi."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Vieta di segnalare messaggi ai moderatori."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Proibisci l'invio di messaggi diretti ai membri."; @@ -3851,8 +4207,17 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Consumo di batteria ridotto"; +/* No comment provided by engineer. */ +"Register" = "Registra"; + +/* token info */ +"Register notification token?" = "Registrare il token di notifica?"; + +/* token status text */ +"Registered" = "Registrato"; + /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Rifiuta"; /* No comment provided by engineer. */ @@ -3861,6 +4226,12 @@ /* No comment provided by engineer. */ "Reject contact request" = "Rifiuta la richiesta di contatto"; +/* alert title */ +"Reject member?" = "Rifiutare il membro?"; + +/* No comment provided by engineer. */ +"rejected" = "rifiutato"; + /* call status */ "rejected call" = "chiamata rifiutata"; @@ -3897,6 +4268,9 @@ /* profile update event chat item */ "removed contact address" = "indirizzo di contatto rimosso"; +/* No comment provided by engineer. */ +"removed from group" = "rimosso dal gruppo"; + /* profile update event chat item */ "removed profile picture" = "immagine del profilo rimossa"; @@ -3930,6 +4304,45 @@ /* chat item action */ "Reply" = "Rispondi"; +/* chat item action */ +"Report" = "Segnala"; + +/* report reason */ +"Report content: only group moderators will see it." = "Segnala contenuto: solo i moderatori del gruppo lo vedranno."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Segnala profilo: solo i moderatori del gruppo lo vedranno."; + +/* report reason */ +"Report other: only group moderators will see it." = "Segnala altro: solo i moderatori del gruppo lo vedranno."; + +/* No comment provided by engineer. */ +"Report reason?" = "Motivo della segnalazione?"; + +/* alert title */ +"Report sent to moderators" = "Segnalazione inviata ai moderatori"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Segnala spam: solo i moderatori del gruppo lo vedranno."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Segnala violazione: solo i moderatori del gruppo lo vedranno."; + +/* report in notification */ +"Report: %@" = "Segnalazione: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "È vietato segnalare messaggi ai moderatori."; + +/* No comment provided by engineer. */ +"Reports" = "Segnalazioni"; + +/* No comment provided by engineer. */ +"request to join rejected" = "richiesta di entrare rifiutata"; + +/* chat list item title */ +"requested to connect" = "richiesto di connettersi"; + /* No comment provided by engineer. */ "Required" = "Obbligatorio"; @@ -3982,10 +4395,19 @@ "Reveal" = "Rivela"; /* No comment provided by engineer. */ -"Review conditions" = "Leggi le condizioni"; +"review" = "revisiona"; /* No comment provided by engineer. */ -"Review later" = "Leggi più tardi"; +"Review conditions" = "Leggi le condizioni"; + +/* admission stage */ +"Review members" = "Revisiona i membri"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Revisiona i membri prima di ammetterli (\"bussare\")."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "revisionato dagli amministratori"; /* No comment provided by engineer. */ "Revoke" = "Revoca"; @@ -4009,12 +4431,15 @@ "Safer groups" = "Gruppi più sicuri"; /* alert button - chat item action */ +chat item action */ "Save" = "Salva"; /* alert button */ "Save (and notify contacts)" = "Salva (e avvisa i contatti)"; +/* alert title */ +"Save admission settings?" = "Salvare le impostazioni di ammissione?"; + /* alert button */ "Save and notify contact" = "Salva e avvisa il contatto"; @@ -4030,6 +4455,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Salva il profilo del gruppo"; +/* No comment provided by engineer. */ +"Save list" = "Salva elenco"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Salva la password e apri la chat"; @@ -4198,6 +4626,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Invia notifiche"; +/* No comment provided by engineer. */ +"Send private reports" = "Invia segnalazioni private"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Invia domande e idee"; @@ -4330,6 +4761,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Imposta 1 giorno"; +/* No comment provided by engineer. */ +"Set chat name…" = "Imposta il nome della chat…"; + /* No comment provided by engineer. */ "Set contact name…" = "Imposta nome del contatto…"; @@ -4342,6 +4776,12 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Impostalo al posto dell'autenticazione di sistema."; +/* No comment provided by engineer. */ +"Set member admission" = "Imposta l'ammissione del membro"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Imposta la scadenza dei messaggi nelle chat."; + /* profile update event chat item */ "set new contact address" = "impostato nuovo indirizzo di contatto"; @@ -4373,7 +4813,7 @@ "Shape profile images" = "Forma delle immagini del profilo"; /* alert action - chat item action */ +chat item action */ "Share" = "Condividi"; /* No comment provided by engineer. */ @@ -4412,6 +4852,9 @@ /* No comment provided by engineer. */ "Share with contacts" = "Condividi con i contatti"; +/* No comment provided by engineer. */ +"Short link" = "Link breve"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Mostra → nei messaggi inviati via instradamento privato."; @@ -4454,8 +4897,11 @@ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "Indirizzo SimpleX o link una tantum?"; +/* simplex link type */ +"SimpleX channel link" = "Link del canale SimpleX"; + /* No comment provided by engineer. */ -"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app"; +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app."; /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La sicurezza di SimpleX Chat è stata verificata da Trail of Bits."; @@ -4538,6 +4984,10 @@ /* notification title */ "Somebody" = "Qualcuno"; +/* blocking reason +report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Quadrata, circolare o qualsiasi forma tra le due."; @@ -4595,6 +5045,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Arresto della chat"; +/* No comment provided by engineer. */ +"Storage" = "Archiviazione"; + /* No comment provided by engineer. */ "strike" = "barrato"; @@ -4664,6 +5117,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Scadenza connessione TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Porta TCP per i messaggi"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4673,12 +5129,15 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Errore del file temporaneo"; /* server test failure */ "Test failed at step %@." = "Test fallito al passo %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Prova le notifiche"; + /* No comment provided by engineer. */ "Test server" = "Prova server"; @@ -4751,15 +5210,9 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Il database vecchio non è stato rimosso durante la migrazione, può essere eliminato."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Il profilo è condiviso solo con i tuoi contatti."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Le stesse condizioni si applicheranno all'operatore **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Le stesse condizioni si applicheranno agli operatori **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Il secondo operatore preimpostato nell'app!"; @@ -4799,6 +5252,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Questa azione non è reversibile: i messaggi inviati e ricevuti in questa chat prima della selezione verranno eliminati."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile."; @@ -4829,9 +5285,15 @@ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Questo è il tuo indirizzo SimpleX!"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Questo link richiede una versione più recente dell'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Questo link è stato usato con un altro dispositivo mobile, creane uno nuovo sul desktop."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Questo messaggio è stato eliminato o non ancora ricevuto."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**."; @@ -4898,6 +5360,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Attiva/disattiva l'incognito quando ti colleghi."; +/* token status */ +"Token status: %@." = "Stato del token: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Opacità barra degli strumenti"; @@ -5006,10 +5471,7 @@ /* authentication reason */ "Unlock app" = "Sblocca l'app"; -/* No comment provided by engineer. */ -"unmute" = "riattiva notifiche"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Riattiva notifiche"; /* No comment provided by engineer. */ @@ -5018,6 +5480,9 @@ /* swipe action */ "Unread" = "Non letto"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Link di connessione non supportato"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Vengono inviati ai nuovi membri fino a 100 ultimi messaggi."; @@ -5033,6 +5498,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Aggiornare le impostazioni?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Condizioni aggiornate"; + /* rcv group event chat item */ "updated group profile" = "ha aggiornato il profilo del gruppo"; @@ -5108,18 +5576,30 @@ /* No comment provided by engineer. */ "Use servers" = "Usa i server"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Usa link brevi (BETA)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Usare i server di SimpleX Chat?"; /* No comment provided by engineer. */ "Use SOCKS proxy" = "Usa proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Usa la porta TCP %@ quando non è specificata alcuna porta."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Usa la porta TCP 443 solo per i server preimpostati."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Usa l'app mentre sei in chiamata."; /* No comment provided by engineer. */ "Use the app with one hand." = "Usa l'app con una mano sola."; +/* No comment provided by engineer. */ +"Use web port" = "Usa porta web"; + /* No comment provided by engineer. */ "User selection" = "Selezione utente"; @@ -5339,6 +5819,9 @@ /* No comment provided by engineer. */ "You accepted connection" = "Hai accettato la connessione"; +/* snd group event chat item */ +"you accepted this member" = "hai accettato questo membro"; + /* No comment provided by engineer. */ "You allow" = "Lo consenti"; @@ -5396,9 +5879,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Puoi cambiarlo nelle impostazioni dell'aspetto."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Puoi configurare gli operatori nelle impostazioni di rete e server."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Puoi configurare i server nelle impostazioni."; @@ -5453,6 +5933,9 @@ /* alert message */ "You can view invitation link again in connection details." = "Puoi vedere di nuovo il link di invito nei dettagli di connessione."; +/* alert message */ +"You can view your reports in Chat with admins." = "Puoi vedere le tue segnalazioni nella chat con gli amministratori."; + /* No comment provided by engineer. */ "You can't send messages!" = "Non puoi inviare messaggi!"; @@ -5525,6 +6008,9 @@ /* chat list item description */ "you shared one-time link incognito" = "hai condiviso un link incognito una tantum"; +/* token info */ +"You should receive notifications." = "Dovresti ricevere le notifiche."; + /* snd group event chat item */ "you unblocked %@" = "hai sbloccato %@"; @@ -5618,15 +6104,15 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Verrà condiviso il tuo profilo **%@**."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Il profilo è condiviso solo con i tuoi contatti."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti. I server di SimpleX non possono vedere il tuo profilo."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Il tuo profilo è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato a tutti i tuoi contatti."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo."; - /* No comment provided by engineer. */ "Your random profile" = "Il tuo profilo casuale"; @@ -5642,6 +6128,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Il tuo indirizzo SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "I tuoi server SMP"; - diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index da0ba42a86..481c21eb8b 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -1,45 +1,30 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (コピー可能)"; /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- [ディレクトリサービス](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) に接続 (ベータ)!\n- 配信証明を送信する (最大 20 人まで)。\n- より速く、より安定。"; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- より安定したメッセージ配信。\n- 改良されたグループ。\n- などなど!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- 任意で削除された連絡先へ通知します。\n- プロフィール名に空白を含めることができます。\n- and more!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 最長 5 分間の音声メッセージ。\n- 消えるまでのカスタム時間。\n- 編集履歴。"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 色付き!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(新規)"; /* No comment provided by engineer. */ "(this device v%@)" = "(このデバイス v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[貢献する](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -76,6 +61,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**推奨**: デバイス トークンと通知は SimpleX Chat 通知サーバーに送信されますが、メッセージの内容、サイズ、送信者は送信されません。"; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**QRスキャン / リンクの貼り付け**: 受け取ったリンクで接続する。"; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**警告**: 即時の プッシュ通知には、キーチェーンに保存されたパスフレーズが必要です。"; @@ -101,7 +89,7 @@ "%@" = "%@"; /* No comment provided by engineer. */ -"%@ (current)" = "%@ (現在)"; +"%@ (current)" = "%@ (現在)"; /* copied message info */ "%@ (current):" = "%@ (現在):"; @@ -136,12 +124,21 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ は検証されています"; +/* No comment provided by engineer. */ +"%@ server" = "%@ サーバー"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ サーバー"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ アップロード済"; /* notification title */ "%@ wants to connect!" = "%@ が接続を希望しています!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@や%@など%lld人のメンバー"; @@ -154,9 +151,24 @@ /* time interval */ "%d days" = "%d 日"; +/* forward confirmation reason */ +"%d file(s) are still being downloaded." = "%d 個のファイルをダウンロードしています。"; + +/* forward confirmation reason */ +"%d file(s) failed to download." = "%d 個のファイルがダウンロードに失敗しました。"; + +/* forward confirmation reason */ +"%d file(s) were deleted." = "%d 個のファイルが削除されました。"; + +/* forward confirmation reason */ +"%d file(s) were not downloaded." = "%d 個のファイルがダウンロードされていません。"; + /* time interval */ "%d hours" = "%d 時"; +/* alert title */ +"%d messages not forwarded" = "%d 個のメッセージが未転送"; + /* time interval */ "%d min" = "%d 分"; @@ -173,7 +185,7 @@ "%d weeks" = "%d 週"; /* No comment provided by engineer. */ -"%lld" = "%lld"; +"%lld" = ""; /* No comment provided by engineer. */ "%lld %@" = "%lld %@"; @@ -208,9 +220,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lldつの新しいインターフェース言語"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld 秒"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld 秒"; @@ -256,7 +265,8 @@ /* No comment provided by engineer. */ "0s" = "0秒"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1日"; /* time interval */ @@ -265,12 +275,20 @@ /* No comment provided by engineer. */ "1 minute" = "1分"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1ヶ月"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1週間"; +/* No comment provided by engineer. */ +"1-time link" = "使い捨てリンク"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "使い捨てリンクは、*ひとつの連絡先にのみ* 使用できます - 対面または任意のチャットで共有してください。"; + /* No comment provided by engineer. */ "5 minutes" = "5分"; @@ -311,8 +329,8 @@ "above, then choose:" = "上で選んでください:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "承諾"; /* No comment provided by engineer. */ @@ -322,7 +340,7 @@ "Accept contact request from %@?" = "%@ からの連絡要求を受け入れますか?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "シークレットモードで承諾"; /* call status */ @@ -346,6 +364,12 @@ /* No comment provided by engineer. */ "Add welcome message" = "ウェルカムメッセージを追加"; +/* No comment provided by engineer. */ +"Added media & file servers" = "追加されたメディア & ファイルサーバー"; + +/* No comment provided by engineer. */ +"Added message servers" = "追加されたメッセージサーバー"; + /* No comment provided by engineer. */ "Address" = "アドレス"; @@ -361,6 +385,9 @@ /* No comment provided by engineer. */ "Advanced network settings" = "ネットワーク詳細設定"; +/* No comment provided by engineer. */ +"Advanced settings" = "詳細設定"; + /* chat item text */ "agreeing encryption for %@…" = "%@の暗号化に同意しています…"; @@ -382,6 +409,9 @@ /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。"; +/* profile dropdown */ +"All profiles" = "すべてのプロフィール"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "あなたの連絡先が繋がったまま継続します。"; @@ -401,7 +431,7 @@ "Allow disappearing messages only if your contact allows it to you." = "連絡先が許可している場合のみ消えるメッセージを許可する。"; /* No comment provided by engineer. */ -"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。"; +"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間)"; /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "連絡先が許可している場合にのみ、メッセージへのリアクションを許可します。"; @@ -415,6 +445,9 @@ /* No comment provided by engineer. */ "Allow sending disappearing messages." = "消えるメッセージの送信を許可する。"; +/* No comment provided by engineer. */ +"Allow sharing" = "共有を許可"; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "送信済みメッセージの永久削除を許可する。(24時間)"; @@ -460,6 +493,9 @@ /* pref value */ "always" = "常に"; +/* No comment provided by engineer. */ +"Always use private routing." = "プライベートルーティングを常に使用する。"; + /* No comment provided by engineer. */ "Always use relay" = "常にリレーを経由する"; @@ -497,7 +533,16 @@ "App version: v%@" = "アプリのバージョン: v%@"; /* No comment provided by engineer. */ -"Appearance" = "見た目"; +"Appearance" = "アピアランス"; + +/* No comment provided by engineer. */ +"Apply" = "適用"; + +/* No comment provided by engineer. */ +"Apply to" = "に適用する"; + +/* No comment provided by engineer. */ +"Archive and upload" = "アーカイブとアップロード"; /* No comment provided by engineer. */ "Attach" = "添付する"; @@ -602,7 +647,7 @@ "Can't invite contacts!" = "連絡先を招待できません!"; /* alert action - alert button */ +alert button */ "Cancel" = "中止"; /* feature offered item */ @@ -642,7 +687,7 @@ "Change self-destruct mode" = "自己破壊モードの変更"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "自己破壊パスコードを変更する"; /* chat item text */ @@ -835,7 +880,7 @@ "Connection" = "接続"; /* No comment provided by engineer. */ -"Connection and servers status." = "接続とサーバーのステータス"; +"Connection and servers status." = "接続とサーバーのステータス。"; /* No comment provided by engineer. */ "Connection error" = "接続エラー"; @@ -912,6 +957,9 @@ /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "[デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻"; +/* No comment provided by engineer. */ +"Create profile" = "プロフィールを作成する"; + /* server test step */ "Create queue" = "キューの作成"; @@ -1020,7 +1068,8 @@ /* message decrypt error item */ "Decryption error" = "復号化エラー"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "デフォルト (%@)"; /* No comment provided by engineer. */ @@ -1030,8 +1079,7 @@ "default (yes)" = "デフォルト(はい)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "削除"; /* No comment provided by engineer. */ @@ -1097,7 +1145,7 @@ /* No comment provided by engineer. */ "Delete message?" = "メッセージを削除しますか?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "メッセージを削除"; /* No comment provided by engineer. */ @@ -1224,12 +1272,12 @@ "Do NOT use SimpleX for emergency calls." = "緊急通報にSimpleXを使用しないでください。"; /* No comment provided by engineer. */ -"Don't create address" = "アドレスを作成しないでください"; +"Don't create address" = "アドレスを作成しない"; /* No comment provided by engineer. */ "Don't enable" = "有効にしない"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "次から表示しない"; /* No comment provided by engineer. */ @@ -1262,7 +1310,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "有効にする(設定の優先を維持)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "自動メッセージ削除を有効にしますか?"; /* No comment provided by engineer. */ @@ -1466,7 +1514,7 @@ /* alert title */ "Error receiving file" = "ファイル受信にエラー発生"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "メンバー除名にエラー発生"; /* No comment provided by engineer. */ @@ -1520,7 +1568,9 @@ /* No comment provided by engineer. */ "Error: " = "エラー : "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "エラー : %@"; /* No comment provided by engineer. */ @@ -1532,9 +1582,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "会話中に無効になっている場合でも。"; -/* No comment provided by engineer. */ -"event happened" = "イベント発生"; - /* No comment provided by engineer. */ "Exit without saving" = "保存せずに閉じる"; @@ -1827,7 +1874,7 @@ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "インストール [ターミナル用SimpleX Chat](https://github.com/simplex-chat/simplex-chat)"; /* No comment provided by engineer. */ -"Instant" = "すぐに"; +"Instant" = "即時"; /* No comment provided by engineer. */ "Instant push notifications will be hidden!\n" = "インスタントプッシュ通知は非表示になります!\n"; @@ -2084,6 +2131,9 @@ /* No comment provided by engineer. */ "Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "メッセージ、ファイル、通話は、前方秘匿性、否認可能性および侵入復元性を備えた**耐量子E2E暗号化**によって保護されます。"; +/* No comment provided by engineer. */ +"Migrate from another device" = "別の端末から移行"; + /* No comment provided by engineer. */ "Migrating database archive…" = "データベースのアーカイブを移行しています…"; @@ -2097,7 +2147,7 @@ "Migration is completed" = "移行が完了しました"; /* No comment provided by engineer. */ -"Migrations:" = "移行"; +"Migrations:" = "移行:"; /* time unit */ "minutes" = "分"; @@ -2132,7 +2182,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "複数チャットのプロフィール"; -/* swipe action */ +/* notification label action */ "Mute" = "ミュート"; /* No comment provided by engineer. */ @@ -2150,7 +2200,7 @@ /* No comment provided by engineer. */ "Network status" = "ネットワーク状況"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "一度も"; /* notification */ @@ -2244,8 +2294,9 @@ "observer" = "オブザーバー"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "オフ"; /* blur media */ @@ -2257,7 +2308,8 @@ /* feature offered item */ "offered %@: %@" = "提供された %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "OK"; /* No comment provided by engineer. */ @@ -2320,7 +2372,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "音声メッセージを送れるのはあなたの連絡相手だけです。"; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "開く"; /* No comment provided by engineer. */ @@ -2431,6 +2483,9 @@ /* No comment provided by engineer. */ "Private filenames" = "プライベートなファイル名"; +/* name of notes to self */ +"Private notes" = "プライベートノート"; + /* No comment provided by engineer. */ "Profile and server connections" = "プロフィールとサーバ接続"; @@ -2549,7 +2604,7 @@ "Reduced battery usage" = "電池使用量低減"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "拒否"; /* No comment provided by engineer. */ @@ -2649,7 +2704,7 @@ "Run chat" = "チャット起動"; /* alert button - chat item action */ +chat item action */ "Save" = "保存"; /* alert button */ @@ -2836,7 +2891,7 @@ "Settings" = "設定"; /* alert action - chat item action */ +chat item action */ "Share" = "共有する"; /* No comment provided by engineer. */ @@ -3058,9 +3113,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "古いデータベースは移行時に削除されなかったので、削除することができます。"; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "長らくお待たせしました! ✅"; @@ -3184,7 +3236,7 @@ /* authentication reason */ "Unlock app" = "アプリのロック解除"; -/* swipe action */ +/* notification label action */ "Unmute" = "ミュート解除"; /* swipe action */ @@ -3388,6 +3440,9 @@ /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "ユーザープロファイルを右にスワイプすると、非表示またはミュートにすることができます。"; +/* No comment provided by engineer. */ +"You can make it visible to your SimpleX contacts via Settings." = "設定でSimpleXの連絡先に表示させることができます。"; + /* notification body */ "You can now chat with %@" = "%@ にメッセージを送信できるようになりました"; @@ -3536,10 +3591,10 @@ "Your profile **%@** will be shared." = "あなたのプロファイル **%@** が共有されます。"; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。 SimpleX サーバーはあなたのプロファイルを参照できません。"; +"Your profile is stored on your device and only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "あなたのプロフィール、連絡先、送信したメッセージがご自分の端末に保存されます。"; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。 SimpleX サーバーはあなたのプロファイルを参照できません。"; /* No comment provided by engineer. */ "Your random profile" = "あなたのランダム・プロフィール"; @@ -3553,6 +3608,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "あなたのSimpleXアドレス"; -/* No comment provided by engineer. */ -"Your SMP servers" = "あなたのSMPサーバ"; - diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 7004d0d124..5caea12ee2 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (kan gekopieerd worden)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- spraakberichten tot 5 minuten.\n- aangepaste tijd om te verdwijnen.\n- bewerkingsgeschiedenis."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 gekleurd!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nieuw)"; /* No comment provided by engineer. */ "(this device v%@)" = "(dit apparaat v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Bijdragen](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -199,6 +178,9 @@ /* time interval */ "%d sec" = "%d sec"; +/* delete after time */ +"%d seconds(s)" = "%d seconden"; + /* integrity error chat item */ "%d skipped message(s)" = "%d overgeslagen bericht(en)"; @@ -241,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nieuwe interface-talen"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld seconde(n)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld seconden"; @@ -289,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 dag"; /* time interval */ @@ -298,12 +278,17 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuut"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 maand"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 week"; +/* delete after time */ +"1 year" = "1 jaar"; + /* No comment provided by engineer. */ "1-time link" = "Eenmalige link"; @@ -356,8 +341,8 @@ "Accent" = "Accent"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Accepteer"; /* No comment provided by engineer. */ @@ -370,7 +355,7 @@ "Accept contact request from %@?" = "Accepteer contactverzoek van %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Accepteer incognito"; /* call status */ @@ -388,6 +373,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Bevestigingsfouten"; +/* token status text */ +"Active" = "actief"; + /* No comment provided by engineer. */ "Active connections" = "Actieve verbindingen"; @@ -397,6 +385,9 @@ /* No comment provided by engineer. */ "Add friends" = "Vrienden toevoegen"; +/* No comment provided by engineer. */ +"Add list" = "Lijst toevoegen"; + /* No comment provided by engineer. */ "Add profile" = "Profiel toevoegen"; @@ -412,6 +403,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Toevoegen aan een ander apparaat"; +/* No comment provided by engineer. */ +"Add to list" = "Toevoegen aan lijst"; + /* No comment provided by engineer. */ "Add welcome message" = "Welkom bericht toevoegen"; @@ -469,12 +463,18 @@ /* chat item text */ "agreeing encryption…" = "versleuteling overeenkomen…"; +/* No comment provided by engineer. */ +"All" = "alle"; + /* No comment provided by engineer. */ "All app data is deleted." = "Alle app-gegevens worden verwijderd."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Alle chats en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Alle chats worden uit de lijst %@ verwijderd en de lijst wordt verwijderd."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Alle gegevens worden bij het invoeren gewist."; @@ -502,6 +502,9 @@ /* profile dropdown */ "All profiles" = "Alle profielen"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Alle rapporten worden voor u gearchiveerd."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Al uw contacten blijven verbonden."; @@ -547,6 +550,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Sta toe om verzonden berichten definitief te verwijderen. (24 uur)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Hiermee kunt u berichten rapporteren aan moderators."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Sta toe om bestanden en media te verzenden."; @@ -601,6 +607,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "en %lld andere gebeurtenissen"; +/* report reason */ +"Another reason" = "Een andere reden"; + /* No comment provided by engineer. */ "Answer call" = "Beantwoord oproep"; @@ -616,6 +625,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "App versleutelt nieuwe lokale bestanden (behalve video's)."; +/* No comment provided by engineer. */ +"App group:" = "App-groep:"; + /* No comment provided by engineer. */ "App icon" = "App icon"; @@ -643,15 +655,36 @@ /* No comment provided by engineer. */ "Apply to" = "Toepassen op"; +/* No comment provided by engineer. */ +"Archive" = "Archief"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "%lld rapporten archiveren?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Alle rapporten archiveren?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiveren en uploaden"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiveer contacten om later te chatten."; +/* No comment provided by engineer. */ +"Archive report" = "Rapport archiveren"; + +/* No comment provided by engineer. */ +"Archive report?" = "Rapport archiveren?"; + +/* swipe action */ +"Archive reports" = "Rapporten archiveren"; + /* No comment provided by engineer. */ "Archived contacts" = "Gearchiveerde contacten"; +/* No comment provided by engineer. */ +"archived report" = "gearchiveerd rapport"; + /* No comment provided by engineer. */ "Archiving database" = "Database archiveren"; @@ -730,6 +763,9 @@ /* No comment provided by engineer. */ "Better groups" = "Betere groepen"; +/* No comment provided by engineer. */ +"Better groups performance" = "Betere prestaties van groepen"; + /* No comment provided by engineer. */ "Better message dates." = "Betere datums voor berichten."; @@ -742,6 +778,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Betere meldingen"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Betere privacy en veiligheid"; + /* No comment provided by engineer. */ "Better security ✅" = "Betere beveiliging ✅"; @@ -773,9 +812,10 @@ "blocked" = "geblokkeerd"; /* rcv group event chat item */ -"blocked %@" = "geblokkeerd %@"; +"blocked %@" = "blokkeerde %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "geblokkeerd door beheerder"; /* No comment provided by engineer. */ @@ -814,9 +854,15 @@ /* No comment provided by engineer. */ "Business chats" = "Zakelijke chats"; +/* No comment provided by engineer. */ +"Businesses" = "bedrijven"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Door SimpleX Chat te gebruiken, gaat u ermee akkoord:\n- alleen legale content te versturen in openbare groepen.\n- andere gebruikers te respecteren – geen spam."; + /* No comment provided by engineer. */ "call" = "bellen"; @@ -857,7 +903,7 @@ "Can't message member" = "Kan geen bericht sturen naar lid"; /* alert action - alert button */ +alert button */ "Cancel" = "Annuleren"; /* No comment provided by engineer. */ @@ -884,6 +930,9 @@ /* No comment provided by engineer. */ "Change" = "Veranderen"; +/* alert title */ +"Change automatic message deletion?" = "Automatisch verwijderen van berichten wijzigen?"; + /* authentication reason */ "Change chat profiles" = "Gebruikersprofielen wijzigen"; @@ -912,7 +961,7 @@ "Change self-destruct mode" = "Zelfvernietigings modus wijzigen"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Zelfvernietigings code wijzigen"; /* chat item text */ @@ -1032,6 +1081,12 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Gesprek wissen?"; +/* No comment provided by engineer. */ +"Clear group?" = "Groep wissen?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Groep wissen of verwijderen?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Privénotities verwijderen?"; @@ -1047,6 +1102,9 @@ /* No comment provided by engineer. */ "colored" = "gekleurd"; +/* report reason */ +"Community guidelines violation" = "Schending van de communityrichtlijnen"; + /* server test step */ "Compare file" = "Bestand vergelijken"; @@ -1071,12 +1129,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Gebruiksvoorwaarden"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Voor ingeschakelde operators worden de voorwaarden na 30 dagen geaccepteerd."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Voorwaarden worden geaccepteerd voor operator(s): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Voorwaarden worden geaccepteerd voor de operator(s): **%@**."; @@ -1089,6 +1141,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "ICE servers configureren"; +/* No comment provided by engineer. */ +"Configure server operators" = "Serveroperators configureren"; + /* No comment provided by engineer. */ "Confirm" = "Bevestigen"; @@ -1119,6 +1174,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Bevestig het uploaden"; +/* token status text */ +"Confirmed" = "Bevestigd"; + /* server test step */ "Connect" = "Verbind"; @@ -1218,6 +1276,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Verbindings- en serverstatus."; +/* No comment provided by engineer. */ +"Connection blocked" = "Verbinding geblokkeerd"; + /* No comment provided by engineer. */ "Connection error" = "Verbindingsfout"; @@ -1227,12 +1288,21 @@ /* chat list item title (it should not be shown */ "connection established" = "verbinding gemaakt"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Verbinding is geblokkeerd door serveroperator:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Verbinding nog niet klaar."; + /* No comment provided by engineer. */ "Connection notifications" = "Verbindingsmeldingen"; /* No comment provided by engineer. */ "Connection request sent!" = "Verbindingsverzoek verzonden!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Verbinding vereist heronderhandeling over encryptie."; + /* No comment provided by engineer. */ "Connection security" = "Beveiliging van de verbinding"; @@ -1293,6 +1363,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Contact personen kunnen berichten markeren voor verwijdering; u kunt ze wel bekijken."; +/* blocking reason */ +"Content violates conditions of use" = "Inhoud schendt de gebruiksvoorwaarden"; + /* No comment provided by engineer. */ "Continue" = "Doorgaan"; @@ -1335,6 +1408,9 @@ /* No comment provided by engineer. */ "Create link" = "Maak link"; +/* No comment provided by engineer. */ +"Create list" = "Maak een lijst"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Maak een nieuw profiel aan in [desktop-app](https://simplex.chat/downloads/). 💻"; @@ -1476,7 +1552,8 @@ /* No comment provided by engineer. */ "decryption errors" = "decoderingsfouten"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "standaard (%@)"; /* No comment provided by engineer. */ @@ -1486,8 +1563,7 @@ "default (yes)" = "standaard (ja)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Verwijderen"; /* No comment provided by engineer. */ @@ -1514,6 +1590,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Chat verwijderen"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Verwijder chatberichten van uw apparaat."; + /* No comment provided by engineer. */ "Delete chat profile" = "Chatprofiel verwijderen"; @@ -1568,13 +1647,16 @@ /* No comment provided by engineer. */ "Delete link?" = "Link verwijderen?"; +/* alert title */ +"Delete list?" = "Lijst verwijderen?"; + /* No comment provided by engineer. */ "Delete member message?" = "Bericht van lid verwijderen?"; /* No comment provided by engineer. */ "Delete message?" = "Verwijder bericht?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Verwijder berichten"; /* No comment provided by engineer. */ @@ -1598,6 +1680,9 @@ /* server test step */ "Delete queue" = "Wachtrij verwijderen"; +/* No comment provided by engineer. */ +"Delete report" = "Rapport verwijderen"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Verwijder maximaal 20 berichten tegelijk."; @@ -1706,6 +1791,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Uitschakelen (overschrijvingen behouden)"; +/* alert title */ +"Disable automatic message deletion?" = "Automatisch verwijderen van berichten uitschakelen?"; + +/* alert button */ +"Disable delete messages" = "Berichten verwijderen uitschakelen"; + /* No comment provided by engineer. */ "Disable for all" = "Uitschakelen voor iedereen"; @@ -1766,6 +1857,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "Gebruik SimpleX NIET voor noodoproepen."; +/* No comment provided by engineer. */ +"Documents:" = "Documenten:"; + /* No comment provided by engineer. */ "Don't create address" = "Maak geen adres aan"; @@ -1773,13 +1867,19 @@ "Don't enable" = "Niet inschakelen"; /* No comment provided by engineer. */ +"Don't miss important messages." = "Mis geen belangrijke berichten."; + +/* alert action */ "Don't show again" = "Niet meer weergeven"; +/* No comment provided by engineer. */ +"Done" = "Klaar"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Downgraden en chat openen"; /* alert button - chat item action */ +chat item action */ "Download" = "Downloaden"; /* No comment provided by engineer. */ @@ -1836,14 +1936,14 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Inschakelen (overschrijvingen behouden)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatisch verwijderen van berichten aanzetten?"; /* No comment provided by engineer. */ "Enable camera access" = "Schakel cameratoegang in"; /* No comment provided by engineer. */ -"Enable Flux" = "Flux inschakelen"; +"Enable Flux in Network & servers settings for better metadata privacy." = "Schakel Flux in bij Netwerk- en serverinstellingen voor betere privacy van metagegevens."; /* No comment provided by engineer. */ "Enable for all" = "Inschakelen voor iedereen"; @@ -1956,6 +2056,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "heronderhandeling van versleuteling vereist voor % @"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Er wordt opnieuw onderhandeld over de encryptie."; + /* No comment provided by engineer. */ "ended" = "geëindigd"; @@ -2031,6 +2134,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Fout bij het overschakelen naar incognito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Fout bij het controleren van de tokenstatus"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw."; @@ -2043,6 +2149,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fout bij maken van groep link"; +/* alert title */ +"Error creating list" = "Fout bij het aanmaken van de lijst"; + /* No comment provided by engineer. */ "Error creating member contact" = "Fout bij aanmaken contact"; @@ -2052,6 +2161,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Fout bij aanmaken van profiel!"; +/* No comment provided by engineer. */ +"Error creating report" = "Fout bij het rapporteren"; + /* No comment provided by engineer. */ "Error decrypting file" = "Fout bij het ontsleutelen van bestand"; @@ -2118,12 +2230,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Fout bij opnieuw verbinden van servers"; -/* No comment provided by engineer. */ +/* alert title */ +"Error registering for notifications" = "Fout bij registreren voor meldingen"; + +/* alert title */ "Error removing member" = "Fout bij verwijderen van lid"; +/* alert title */ +"Error reordering lists" = "Fout bij het opnieuw ordenen van lijsten"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Fout bij het resetten van statistieken"; +/* alert title */ +"Error saving chat list" = "Fout bij het opslaan van chatlijst"; + /* No comment provided by engineer. */ "Error saving group profile" = "Fout bij opslaan van groep profiel"; @@ -2175,6 +2296,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Fout bij het synchroniseren van de verbinding"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Fout bij het testen van de serververbinding"; + /* No comment provided by engineer. */ "Error updating group link" = "Fout bij bijwerken van groep link"; @@ -2199,7 +2323,9 @@ /* No comment provided by engineer. */ "Error: " = "Fout: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Fout: %@"; /* No comment provided by engineer. */ @@ -2217,9 +2343,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Zelfs wanneer uitgeschakeld in het gesprek."; -/* No comment provided by engineer. */ -"event happened" = "gebeurtenis gebeurd"; - /* No comment provided by engineer. */ "Exit without saving" = "Afsluiten zonder opslaan"; @@ -2229,6 +2352,9 @@ /* No comment provided by engineer. */ "expired" = "verlopen"; +/* token status text */ +"Expired" = "Verlopen"; + /* No comment provided by engineer. */ "Export database" = "Database exporteren"; @@ -2253,18 +2379,30 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Snel en niet wachten tot de afzender online is!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Sneller verwijderen van groepen."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Snellere deelname en betrouwbaardere berichten."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Sneller verzenden van berichten."; + /* swipe action */ "Favorite" = "Favoriet"; /* No comment provided by engineer. */ +"Favorites" = "Favorieten"; + +/* file error alert title */ "File error" = "Bestandsfout"; /* alert message */ "File errors:\n%@" = "Bestandsfouten:\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Bestand is geblokkeerd door serveroperator:\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Bestand niet gevonden - hoogstwaarschijnlijk is het bestand verwijderd of geannuleerd."; @@ -2341,7 +2479,7 @@ "Fix not supported by group member" = "Herstel wordt niet ondersteund door groepslid"; /* No comment provided by engineer. */ -"for better metadata privacy." = "voor betere privacy van metagegevens."; +"For all moderators" = "Voor alle moderators"; /* servers error */ "For chat profile %@:" = "Voor chatprofiel %@:"; @@ -2352,6 +2490,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Als uw contactpersoon bijvoorbeeld berichten ontvangt via een SimpleX Chat-server, worden deze door uw app via een Flux-server verzonden."; +/* No comment provided by engineer. */ +"For me" = "Voor mij"; + /* No comment provided by engineer. */ "For private routing" = "Voor privé-routering"; @@ -2424,6 +2565,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Verder verminderd batterij verbruik"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Ontvang een melding als u vermeld wordt."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF's en stickers"; @@ -2496,9 +2640,15 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "De groep wordt voor u verwijderd, dit kan niet ongedaan worden gemaakt!"; +/* No comment provided by engineer. */ +"Groups" = "Groepen"; + /* No comment provided by engineer. */ "Help" = "Help"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Help beheerders bij het modereren van hun groepen."; + /* No comment provided by engineer. */ "Hidden" = "Verborgen"; @@ -2535,6 +2685,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Hoe het de privacy helpt"; +/* alert button */ +"How it works" = "Hoe het werkt"; + /* No comment provided by engineer. */ "How SimpleX works" = "Hoe SimpleX werkt"; @@ -2622,6 +2775,12 @@ /* No comment provided by engineer. */ "inactive" = "inactief"; +/* report reason */ +"Inappropriate content" = "Ongepaste inhoud"; + +/* report reason */ +"Inappropriate profile" = "Ongepast profiel"; + /* No comment provided by engineer. */ "Incognito" = "Incognito"; @@ -2688,6 +2847,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Interface kleuren"; +/* token status text */ +"Invalid" = "Ongeldig"; + +/* token status text */ +"Invalid (bad token)" = "Ongeldig (ongeldig token)"; + +/* token status text */ +"Invalid (expired)" = "Ongeldig (verlopen)"; + +/* token status text */ +"Invalid (unregistered)" = "Ongeldig (niet geregistreerd)"; + +/* token status text */ +"Invalid (wrong topic)" = "Ongeldig (verkeerd onderwerp)"; + /* invalid chat data */ "invalid chat" = "ongeldige gesprek"; @@ -2800,7 +2974,7 @@ "Japanese interface" = "Japanse interface"; /* swipe action */ -"Join" = "Word lid van"; +"Join" = "Word lid"; /* No comment provided by engineer. */ "join as %@" = "deelnemen als %@"; @@ -2889,6 +3063,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Gelinkte desktops"; +/* swipe action */ +"List" = "Lijst"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn."; + +/* No comment provided by engineer. */ +"List name..." = "Naam van lijst..."; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2958,6 +3141,9 @@ /* item status text */ "Member inactive" = "Lid inactief"; +/* chat feature */ +"Member reports" = "Ledenrapporten"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "De rol van het lid wordt gewijzigd naar \"%@\". Alle chatleden worden op de hoogte gebracht."; @@ -2979,6 +3165,9 @@ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Groepsleden kunnen verzonden berichten onherroepelijk verwijderen. (24 uur)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Leden kunnen berichten melden bij moderators."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Groepsleden kunnen directe berichten sturen."; @@ -2994,6 +3183,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "Groepsleden kunnen spraak berichten verzenden."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Vermeld leden 👋"; + /* No comment provided by engineer. */ "Menus" = "Menu's"; @@ -3066,6 +3258,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Berichten van %@ worden getoond!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Berichten in deze chat zullen nooit worden verwijderd."; + /* No comment provided by engineer. */ "Messages received" = "Berichten ontvangen"; @@ -3138,9 +3333,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "gemodereerd door %@"; +/* member role */ +"moderator" = "moderator"; + /* time unit */ "months" = "maanden"; +/* swipe action */ +"More" = "Meer"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Meer verbeteringen volgen snel!"; @@ -3156,12 +3357,12 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Meerdere chatprofielen"; -/* No comment provided by engineer. */ -"mute" = "dempen"; - -/* swipe action */ +/* notification label action */ "Mute" = "Dempen"; +/* notification label action */ +"Mute all" = "Alles dempen"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Gedempt wanneer inactief!"; @@ -3192,9 +3393,12 @@ /* No comment provided by engineer. */ "Network status" = "Netwerk status"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nooit"; +/* token status text */ +"New" = "Nieuw"; + /* No comment provided by engineer. */ "New chat" = "Nieuw gesprek"; @@ -3255,6 +3459,15 @@ /* Authentication unavailable */ "No app password" = "Geen app wachtwoord"; +/* No comment provided by engineer. */ +"No chats" = "Geen chats"; + +/* No comment provided by engineer. */ +"No chats found" = "Geen chats gevonden"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Geen chats in lijst %@"; + /* No comment provided by engineer. */ "No contacts selected" = "Geen contacten geselecteerd"; @@ -3288,6 +3501,9 @@ /* servers error */ "No media & file servers." = "Geen media- en bestandsservers."; +/* No comment provided by engineer. */ +"No message" = "Geen bericht"; + /* servers error */ "No message servers." = "Geen berichtenservers."; @@ -3324,12 +3540,21 @@ /* copied message info in history */ "no text" = "geen tekst"; +/* alert title */ +"No token!" = "Geen token!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Geen ongelezen chats"; + /* No comment provided by engineer. */ "No user identifiers." = "Geen gebruikers-ID's."; /* No comment provided by engineer. */ "Not compatible!" = "Niet compatibel!"; +/* No comment provided by engineer. */ +"Notes" = "Notities"; + /* No comment provided by engineer. */ "Nothing selected" = "Niets geselecteerd"; @@ -3342,9 +3567,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Meldingen zijn uitgeschakeld!"; +/* alert title */ +"Notifications error" = "Meldingsfout"; + /* No comment provided by engineer. */ "Notifications privacy" = "Privacy van meldingen"; +/* alert title */ +"Notifications status" = "Meldingsstatus"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Nu kunnen beheerders: \n- berichten van leden verwijderen.\n- schakel leden uit (\"waarnemer\" rol)"; @@ -3352,8 +3583,9 @@ "observer" = "Waarnemer"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "uit"; /* blur media */ @@ -3365,7 +3597,8 @@ /* feature offered item */ "offered %@: %@" = "voorgesteld %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "OK"; /* No comment provided by engineer. */ @@ -3407,6 +3640,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Alleen groep eigenaren kunnen spraak berichten inschakelen."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Alleen de verzender en moderators zien het"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Alleen jij en moderators zien het"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Alleen jij kunt bericht reacties toevoegen."; @@ -3437,7 +3676,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Alleen uw contact kan spraak berichten verzenden."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Open"; /* No comment provided by engineer. */ @@ -3488,6 +3727,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "Of om privé te delen"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organiseer chats in lijsten"; + /* No comment provided by engineer. */ "other" = "overig"; @@ -3545,9 +3787,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "In behandeling"; + /* No comment provided by engineer. */ "Pending" = "in behandeling"; +/* No comment provided by engineer. */ +"pending approval" = "in afwachting van goedkeuring"; + /* No comment provided by engineer. */ "Periodic" = "Periodiek"; @@ -3614,6 +3862,15 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bewaar het wachtwoord veilig, u kunt deze NIET wijzigen als u het kwijtraakt."; +/* token info */ +"Please try to disable and re-enable notfications." = "Probeer meldingen uit en weer in te schakelen."; + +/* token info */ +"Please wait for token activation to complete." = "Wacht tot de tokenactivering voltooid is."; + +/* token info */ +"Please wait for token to be registered." = "Wacht tot het token is geregistreerd."; + /* No comment provided by engineer. */ "Polish interface" = "Poolse interface"; @@ -3644,12 +3901,21 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy voor uw klanten."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Privacybeleid en gebruiksvoorwaarden."; + /* No comment provided by engineer. */ "Privacy redefined" = "Privacy opnieuw gedefinieerd"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders."; + /* No comment provided by engineer. */ "Private filenames" = "Privé bestandsnamen"; +/* No comment provided by engineer. */ +"Private media file names." = "Namen van persoonlijke mediabestanden."; + /* No comment provided by engineer. */ "Private message routing" = "Routering van privéberichten"; @@ -3695,6 +3961,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Berichten reacties verbieden."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Het melden van berichten aan moderators is niet toegestaan."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Verbied het sturen van directe berichten naar leden."; @@ -3857,8 +4126,17 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Verminderd batterijgebruik"; +/* No comment provided by engineer. */ +"Register" = "Register"; + +/* token info */ +"Register notification token?" = "Meldingstoken registreren?"; + +/* token status text */ +"Registered" = "Geregistreerd"; + /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Afwijzen"; /* No comment provided by engineer. */ @@ -3867,6 +4145,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Contactverzoek afwijzen"; +/* No comment provided by engineer. */ +"rejected" = "afgewezen"; + /* call status */ "rejected call" = "geweigerde oproep"; @@ -3936,8 +4217,38 @@ /* chat item action */ "Reply" = "Antwoord"; +/* chat item action */ +"Report" = "rapporteren"; + +/* report reason */ +"Report content: only group moderators will see it." = "Inhoud melden: alleen groepsmoderators kunnen dit zien."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Rapporteer ledenprofiel: alleen groepsmoderators kunnen dit zien."; + +/* report reason */ +"Report other: only group moderators will see it." = "Anders melden: alleen groepsmoderators kunnen het zien."; + +/* No comment provided by engineer. */ +"Report reason?" = "Reden melding?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Spam melden: alleen groepsmoderators kunnen het zien."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Rapporteer overtreding: alleen groepsmoderators kunnen dit zien."; + +/* report in notification */ +"Report: %@" = "rapporteer: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Het is niet toegestaan om berichten aan moderators te melden."; + +/* No comment provided by engineer. */ +"Reports" = "Rapporten"; + /* chat list item title */ -"requested to connect" = "gevraagd om verbinding te maken"; +"requested to connect" = "verzocht om verbinding te maken"; /* No comment provided by engineer. */ "Required" = "Vereist"; @@ -3993,9 +4304,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Voorwaarden bekijken"; -/* No comment provided by engineer. */ -"Review later" = "Later beoordelen"; - /* No comment provided by engineer. */ "Revoke" = "Intrekken"; @@ -4018,7 +4326,7 @@ "Safer groups" = "Veiligere groepen"; /* alert button - chat item action */ +chat item action */ "Save" = "Opslaan"; /* alert button */ @@ -4040,7 +4348,10 @@ "Save group profile" = "Groep profiel opslaan"; /* No comment provided by engineer. */ -"Save passphrase and open chat" = "Bewaar het wachtwoord en open je chats"; +"Save list" = "Lijst opslaan"; + +/* No comment provided by engineer. */ +"Save passphrase and open chat" = "Wachtwoord opslaan en open je chats"; /* No comment provided by engineer. */ "Save passphrase in Keychain" = "Sla het wachtwoord op in de Keychain"; @@ -4207,6 +4518,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Meldingen verzenden"; +/* No comment provided by engineer. */ +"Send private reports" = "Rapporteer privé"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Stuur vragen en ideeën"; @@ -4339,6 +4653,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Stel 1 dag in"; +/* No comment provided by engineer. */ +"Set chat name…" = "Stel chatnaam in…"; + /* No comment provided by engineer. */ "Set contact name…" = "Contactnaam instellen…"; @@ -4351,11 +4668,14 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Stel het in in plaats van systeemverificatie."; +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Stel de berichtvervaldatum in chats in."; + /* profile update event chat item */ "set new contact address" = "nieuw contactadres instellen"; /* profile update event chat item */ -"set new profile picture" = "nieuwe profielfoto instellen"; +"set new profile picture" = "nieuwe profielfoto"; /* No comment provided by engineer. */ "Set passcode" = "Toegangscode instellen"; @@ -4382,7 +4702,7 @@ "Shape profile images" = "Vorm profiel afbeeldingen"; /* alert action - chat item action */ +chat item action */ "Share" = "Deel"; /* No comment provided by engineer. */ @@ -4547,6 +4867,10 @@ /* notification title */ "Somebody" = "Iemand"; +/* blocking reason +report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Vierkant, cirkel of iets daartussenin."; @@ -4604,6 +4928,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Chat stoppen"; +/* No comment provided by engineer. */ +"Storage" = "Opslag"; + /* No comment provided by engineer. */ "strike" = "staking"; @@ -4611,7 +4938,7 @@ "Strong" = "Krachtig"; /* No comment provided by engineer. */ -"Submit" = "Indienen"; +"Submit" = "Bevestigen"; /* No comment provided by engineer. */ "Subscribed" = "Subscribed"; @@ -4673,6 +5000,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Timeout van TCP-verbinding"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-poort voor berichtenuitwisseling"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4682,12 +5012,15 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Tijdelijke bestandsfout"; /* server test failure */ "Test failed at step %@." = "Test mislukt bij stap %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Testmeldingen"; + /* No comment provided by engineer. */ "Test server" = "Server test"; @@ -4760,15 +5093,9 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "De oude database is niet verwijderd tijdens de migratie, deze kan worden verwijderd."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Het profiel wordt alleen gedeeld met uw contacten."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dezelfde voorwaarden gelden voor operator **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Dezelfde voorwaarden gelden voor operator(s): **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "De tweede vooraf ingestelde operator in de app!"; @@ -4808,6 +5135,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Deze actie kan niet ongedaan worden gemaakt, de berichten die eerder zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. Het kan enkele minuten duren."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Deze actie kan niet ongedaan worden gemaakt. De berichten die eerder in deze chat zijn verzonden en ontvangen dan geselecteerd, worden verwijderd."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Deze actie kan niet ongedaan worden gemaakt. Uw profiel, contacten, berichten en bestanden gaan definitief verloren."; @@ -4821,7 +5151,7 @@ "this contact" = "dit contact"; /* No comment provided by engineer. */ -"This device name" = "Deze apparaatnaam"; +"This device name" = "Naam van dit apparaat"; /* No comment provided by engineer. */ "This display name is invalid. Please choose another name." = "Deze weergavenaam is ongeldig. Kies een andere naam."; @@ -4841,6 +5171,9 @@ /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Deze link is gebruikt met een ander mobiel apparaat. Maak een nieuwe link op de desktop."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Dit bericht is verwijderd of nog niet ontvangen."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Deze instelling is van toepassing op berichten in je huidige chatprofiel **%@**."; @@ -4907,6 +5240,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Schakel incognito in tijdens het verbinden."; +/* token status */ +"Token status: %@." = "Tokenstatus: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "De transparantie van de werkbalk"; @@ -5015,10 +5351,7 @@ /* authentication reason */ "Unlock app" = "Ontgrendel app"; -/* No comment provided by engineer. */ -"unmute" = "dempen opheffen"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Dempen opheffen"; /* No comment provided by engineer. */ @@ -5042,6 +5375,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Instellingen actualiseren?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Bijgewerkte voorwaarden"; + /* rcv group event chat item */ "updated group profile" = "bijgewerkt groep profiel"; @@ -5123,12 +5459,18 @@ /* No comment provided by engineer. */ "Use SOCKS proxy" = "Gebruik SOCKS proxy"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Gebruik TCP-poort %@ als er geen poort is opgegeven."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Gebruik de app tijdens het gesprek."; /* No comment provided by engineer. */ "Use the app with one hand." = "Gebruik de app met één hand."; +/* No comment provided by engineer. */ +"Use web port" = "Gebruik een webpoort"; + /* No comment provided by engineer. */ "User selection" = "Gebruikersselectie"; @@ -5136,7 +5478,7 @@ "Username" = "Gebruikersnaam"; /* No comment provided by engineer. */ -"Using SimpleX Chat servers." = "SimpleX Chat servers gebruiken."; +"Using SimpleX Chat servers." = "Gebruik SimpleX Chat servers."; /* No comment provided by engineer. */ "v%@" = "v%@"; @@ -5405,9 +5747,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "U kunt dit wijzigen in de instellingen onder uiterlijk."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "U kunt operators configureren in Netwerk- en serverinstellingen."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "U kunt servers configureren via instellingen."; @@ -5534,6 +5873,9 @@ /* chat list item description */ "you shared one-time link incognito" = "je hebt een eenmalige link incognito gedeeld"; +/* token info */ +"You should receive notifications." = "U zou meldingen moeten ontvangen."; + /* snd group event chat item */ "you unblocked %@" = "je hebt %@ gedeblokkeerd"; @@ -5627,15 +5969,15 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Uw profiel **%@** wordt gedeeld."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Het profiel wordt alleen gedeeld met uw contacten."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Uw profiel wordt op uw apparaat opgeslagen en alleen gedeeld met uw contacten. SimpleX servers kunnen uw profiel niet zien."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Je profiel is gewijzigd. Als je het opslaat, wordt het bijgewerkte profiel naar al je contacten verzonden."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Uw profiel, contacten en afgeleverde berichten worden op uw apparaat opgeslagen."; - /* No comment provided by engineer. */ "Your random profile" = "Je willekeurige profiel"; @@ -5651,6 +5993,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Uw SimpleX adres"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Uw SMP servers"; - diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index cc3bd228f9..e3e860e329 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (można skopiować)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- wiadomości głosowe do 5 minut.\n- niestandardowy czas zniknięcia.\n- historia edycji."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 pokolorowany!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nowy)"; /* No comment provided by engineer. */ "(this device v%@)" = "(to urządzenie v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Przyczyń się](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -82,6 +61,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Zalecane**: do serwera powiadomień SimpleX Chat wysyłany jest token urządzenia i powiadomienia, lecz nie treść wiadomości, jej rozmiar lub od kogo ona jest."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**Zeskanuj / Wklej link**: aby połączyć się za pomocą otrzymanego linku."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Uwaga**: Natychmiastowe powiadomienia push wymagają zapisania kodu dostępu w Keychain."; @@ -142,6 +124,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ jest zweryfikowany"; +/* No comment provided by engineer. */ +"%@ server" = "%@ serwer"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ serwery/ów"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ wgrane"; @@ -190,6 +178,9 @@ /* time interval */ "%d sec" = "%d sek"; +/* delete after time */ +"%d seconds(s)" = "%d sekundach"; + /* integrity error chat item */ "%d skipped message(s)" = "%d pominięte wiadomość(i)"; @@ -232,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nowe języki interfejsu"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld sekund(y)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld sekund"; @@ -280,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 dzień"; /* time interval */ @@ -289,12 +278,23 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuta"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 miesiąc"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 tydzień"; +/* delete after time */ +"1 year" = "1 roku"; + +/* No comment provided by engineer. */ +"1-time link" = "link jednorazowy"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Link jednorazowy może być użyty *tylko z jednym kontaktem* - udostępnij go osobiście lub przez dowolny komunikator."; + /* No comment provided by engineer. */ "5 minutes" = "5 minut"; @@ -328,6 +328,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Przerwać zmianę adresu?"; +/* No comment provided by engineer. */ +"About operators" = "O operatorach"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "O SimpleX Chat"; @@ -338,10 +341,13 @@ "Accent" = "Akcent"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Akceptuj"; +/* No comment provided by engineer. */ +"Accept conditions" = "Zaakceptuj warunki"; + /* No comment provided by engineer. */ "Accept connection request?" = "Zaakceptować prośbę o połączenie?"; @@ -349,24 +355,36 @@ "Accept contact request from %@?" = "Zaakceptuj prośbę o kontakt od %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Akceptuj incognito"; /* call status */ "accepted call" = "zaakceptowane połączenie"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Zaakceptowano warunki"; + /* No comment provided by engineer. */ "Acknowledged" = "Potwierdzono"; /* No comment provided by engineer. */ "Acknowledgement errors" = "Błędy potwierdzenia"; +/* token status text */ +"Active" = "Aktywne"; + /* No comment provided by engineer. */ "Active connections" = "Aktywne połączenia"; /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów."; +/* No comment provided by engineer. */ +"Add friends" = "Dodaj znajomych"; + +/* No comment provided by engineer. */ +"Add list" = "Dodaj listę"; + /* No comment provided by engineer. */ "Add profile" = "Dodaj profil"; @@ -376,12 +394,27 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Dodaj serwery, skanując kody QR."; +/* No comment provided by engineer. */ +"Add team members" = "Dodaj członków zespołu"; + /* No comment provided by engineer. */ "Add to another device" = "Dodaj do innego urządzenia"; +/* No comment provided by engineer. */ +"Add to list" = "Dodaj do listy"; + /* No comment provided by engineer. */ "Add welcome message" = "Dodaj wiadomość powitalną"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Dodaj członków zespołu do konwersacji."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Dodano serwery multimediów i plików"; + +/* No comment provided by engineer. */ +"Added message servers" = "Dodano serwery wiadomości"; + /* No comment provided by engineer. */ "Additional accent" = "Dodatkowy akcent"; @@ -397,6 +430,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Zmiana adresu zostanie przerwana. Użyty zostanie stary adres odbiorczy."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Adres czy jednorazowy link?"; + +/* No comment provided by engineer. */ +"Address settings" = "Ustawienia adresu"; + /* member role */ "admin" = "administrator"; @@ -421,12 +460,18 @@ /* chat item text */ "agreeing encryption…" = "uzgadnianie szyfrowania…"; +/* No comment provided by engineer. */ +"All" = "Wszystko"; + /* No comment provided by engineer. */ "All app data is deleted." = "Wszystkie dane aplikacji są usunięte."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Wszystkie czaty i wiadomości zostaną usunięte - nie można tego cofnąć!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Wszystkie rozmowy zostaną usunięte z listy %@, a lista usunięta."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Wszystkie dane są usuwane po jego wprowadzeniu."; @@ -439,6 +484,9 @@ /* feature role */ "all members" = "wszyscy członkowie"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Wszystkie wiadomości zostaną usunięte – nie można tego cofnąć!"; @@ -451,6 +499,9 @@ /* profile dropdown */ "All profiles" = "Wszystkie profile"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Wszystkie raporty zostaną dla Ciebie zarchiwizowane."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Wszystkie Twoje kontakty pozostaną połączone."; @@ -496,6 +547,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Zezwól na nieodwracalne usunięcie wysłanych wiadomości. (24 godziny)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Zezwól na zgłaszanie wiadomości moderatorom."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Pozwól na wysyłanie plików i mediów."; @@ -550,6 +604,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "i %lld innych wydarzeń"; +/* report reason */ +"Another reason" = "Inny powód"; + /* No comment provided by engineer. */ "Answer call" = "Odbierz połączenie"; @@ -565,6 +622,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Aplikacja szyfruje nowe lokalne pliki (bez filmów)."; +/* No comment provided by engineer. */ +"App group:" = "Grupa aplikacji:"; + /* No comment provided by engineer. */ "App icon" = "Ikona aplikacji"; @@ -592,12 +652,30 @@ /* No comment provided by engineer. */ "Apply to" = "Zastosuj dla"; +/* No comment provided by engineer. */ +"Archive" = "Archiwizuj"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiwizować %lld reports?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archiwizować wszystkie zgłoszenia?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiwizuj i prześlij"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiwizuj kontakty aby porozmawiać później."; +/* No comment provided by engineer. */ +"Archive report" = "Archiwizuj zgłoszenie"; + +/* No comment provided by engineer. */ +"Archive report?" = "Archiwizować zgłoszenie?"; + +/* swipe action */ +"Archive reports" = "Archiwizuj zgłoszenia"; + /* No comment provided by engineer. */ "Archived contacts" = "Zarchiwizowane kontakty"; @@ -673,15 +751,30 @@ /* No comment provided by engineer. */ "Bad message ID" = "Zły identyfikator wiadomości"; +/* No comment provided by engineer. */ +"Better calls" = "Lepsze połączenia"; + /* No comment provided by engineer. */ "Better groups" = "Lepsze grupy"; +/* No comment provided by engineer. */ +"Better message dates." = "Lepsze daty wiadomości."; + /* No comment provided by engineer. */ "Better messages" = "Lepsze wiadomości"; /* No comment provided by engineer. */ "Better networking" = "Lepsze sieciowanie"; +/* No comment provided by engineer. */ +"Better notifications" = "Lepsze powiadomienia"; + +/* No comment provided by engineer. */ +"Better security ✅" = "Lepsze zabezpieczenia ✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "Lepszy interfejs użytkownika"; + /* No comment provided by engineer. */ "Black" = "Czarny"; @@ -709,7 +802,8 @@ /* rcv group event chat item */ "blocked %@" = "zablokowany %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "zablokowany przez admina"; /* No comment provided by engineer. */ @@ -742,6 +836,15 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bułgarski, fiński, tajski i ukraiński – dzięki użytkownikom i [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Adres firmowy"; + +/* No comment provided by engineer. */ +"Business chats" = "Czaty biznesowe"; + +/* No comment provided by engineer. */ +"Businesses" = "Firmy"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -785,7 +888,7 @@ "Can't message member" = "Nie można wysłać wiadomości do członka"; /* alert action - alert button */ +alert button */ "Cancel" = "Anuluj"; /* No comment provided by engineer. */ @@ -812,6 +915,9 @@ /* No comment provided by engineer. */ "Change" = "Zmień"; +/* authentication reason */ +"Change chat profiles" = "Zmień profil czatu"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Zmienić hasło bazy danych?"; @@ -837,7 +943,7 @@ "Change self-destruct mode" = "Zmień tryb samozniszczenia"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Zmień pin samozniszczenia"; /* chat item text */ @@ -855,6 +961,15 @@ /* chat item text */ "changing address…" = "zmiana adresu…"; +/* No comment provided by engineer. */ +"Chat" = "Czat"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Czat już istnieje"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Czat już istnieje!"; + /* No comment provided by engineer. */ "Chat colors" = "Kolory czatu"; @@ -868,10 +983,10 @@ "Chat database deleted" = "Baza danych czatu usunięta"; /* No comment provided by engineer. */ -"Chat database exported" = "Wyeksportowano bazę danych czatu"; +"Chat database exported" = "Wyeksportowano bazę danych czatów"; /* No comment provided by engineer. */ -"Chat database imported" = "Zaimportowano bazę danych czatu"; +"Chat database imported" = "Zaimportowano bazę danych czatów"; /* No comment provided by engineer. */ "Chat is running" = "Czat jest uruchomiony"; @@ -900,9 +1015,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Motyw czatu"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Czat zostanie usunięty dla wszystkich członków – tej operacji nie można cofnąć!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć!"; + /* No comment provided by engineer. */ "Chats" = "Czaty"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Sprawdzaj wiadomości co 20 min."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "Sprawdź wiadomości, gdy będzie to dopuszczone."; + /* alert title */ "Check server address and try again." = "Sprawdź adres serwera i spróbuj ponownie."; @@ -963,6 +1090,18 @@ /* No comment provided by engineer. */ "Completed" = "Zakończono"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Warunki zaakceptowane dnia: %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Warunki zostały zaakceptowane przez operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Warunki użytkowania"; + /* No comment provided by engineer. */ "Configure ICE servers" = "Skonfiguruj serwery ICE"; @@ -1341,7 +1480,8 @@ /* No comment provided by engineer. */ "decryption errors" = "błąd odszyfrowywania"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "domyślne (%@)"; /* No comment provided by engineer. */ @@ -1351,8 +1491,7 @@ "default (yes)" = "domyślnie (tak)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Usuń"; /* No comment provided by engineer. */ @@ -1433,7 +1572,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Usunąć wiadomość?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Usuń wiadomości"; /* No comment provided by engineer. */ @@ -1622,14 +1761,14 @@ /* No comment provided by engineer. */ "Don't enable" = "Nie włączaj"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Nie pokazuj ponownie"; /* No comment provided by engineer. */ "Downgrade and open chat" = "Obniż wersję i otwórz czat"; /* alert button - chat item action */ +chat item action */ "Download" = "Pobierz"; /* No comment provided by engineer. */ @@ -1683,7 +1822,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Włącz (zachowaj nadpisania)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Czy włączyć automatyczne usuwanie wiadomości?"; /* No comment provided by engineer. */ @@ -1953,7 +2092,7 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Błąd ponownego łączenia serwerów"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Błąd usuwania członka"; /* No comment provided by engineer. */ @@ -2028,7 +2167,9 @@ /* No comment provided by engineer. */ "Error: " = "Błąd: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Błąd: %@"; /* No comment provided by engineer. */ @@ -2043,9 +2184,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Nawet po wyłączeniu w rozmowie."; -/* No comment provided by engineer. */ -"event happened" = "nowe wydarzenie"; - /* No comment provided by engineer. */ "Exit without saving" = "Wyjdź bez zapisywania"; @@ -2085,7 +2223,7 @@ /* swipe action */ "Favorite" = "Ulubione"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Błąd pliku"; /* alert message */ @@ -2937,10 +3075,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Wiele profili czatu"; -/* No comment provided by engineer. */ -"mute" = "wycisz"; - -/* swipe action */ +/* notification label action */ "Mute" = "Wycisz"; /* No comment provided by engineer. */ @@ -2967,7 +3102,7 @@ /* No comment provided by engineer. */ "Network status" = "Status sieci"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nigdy"; /* No comment provided by engineer. */ @@ -3100,8 +3235,9 @@ "observer" = "obserwator"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "wyłączony"; /* blur media */ @@ -3113,7 +3249,8 @@ /* feature offered item */ "offered %@: %@" = "zaoferował %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3182,7 +3319,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Tylko Twój kontakt może wysyłać wiadomości głosowe."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Otwórz"; /* No comment provided by engineer. */ @@ -3579,7 +3716,7 @@ "Reduced battery usage" = "Zmniejszone zużycie baterii"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Odrzuć"; /* No comment provided by engineer. */ @@ -3730,7 +3867,7 @@ "Safer groups" = "Bezpieczniejsze grupy"; /* alert button - chat item action */ +chat item action */ "Save" = "Zapisz"; /* alert button */ @@ -4082,7 +4219,7 @@ "Shape profile images" = "Kształtuj obrazy profilowe"; /* alert action - chat item action */ +chat item action */ "Share" = "Udostępnij"; /* No comment provided by engineer. */ @@ -4349,7 +4486,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Tymczasowy błąd pliku"; /* server test failure */ @@ -4421,9 +4558,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stara baza danych nie została usunięta podczas migracji, można ją usunąć."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Profil jest udostępniany tylko Twoim kontaktom."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Drugi tik, który przegapiliśmy! ✅"; @@ -4646,10 +4780,7 @@ /* authentication reason */ "Unlock app" = "Odblokuj aplikację"; -/* No comment provided by engineer. */ -"unmute" = "wyłącz wyciszenie"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Wyłącz wyciszenie"; /* No comment provided by engineer. */ @@ -5222,27 +5353,27 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Twój profil **%@** zostanie udostępniony."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Profil jest udostępniany tylko Twoim kontaktom."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Twój profil jest przechowywany na urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie mogą zobaczyć Twojego profilu."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Twój profil został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do wszystkich kontaktów."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Twój profil, kontakty i dostarczone wiadomości są przechowywane na Twoim urządzeniu."; - /* No comment provided by engineer. */ "Your random profile" = "Twój losowy profil"; /* No comment provided by engineer. */ "Your server address" = "Twój adres serwera"; +/* No comment provided by engineer. */ +"Your servers" = "Twoje serwery"; + /* No comment provided by engineer. */ "Your settings" = "Twoje ustawienia"; /* No comment provided by engineer. */ "Your SimpleX address" = "Twój adres SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Twoje serwery SMP"; - diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index dcd3de19d1..70cd739531 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (можно скопировать)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- голосовые сообщения до 5 минут.\n- настройка времени исчезающих сообщений.\n- история редактирования."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 цвет!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(новое)"; /* No comment provided by engineer. */ "(this device v%@)" = "(это устройство v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внести свой вклад](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -164,7 +143,7 @@ "%@, %@ and %lld members" = "%@, %@ и %lld членов группы"; /* No comment provided by engineer. */ -"%@, %@ and %lld other members connected" = "%@, %@ и %lld других членов соединены"; +"%@, %@ and %lld other members connected" = "установлено соединение с %@, %@ и %lld другими членами группы"; /* copied message info */ "%@:" = "%@:"; @@ -185,7 +164,7 @@ "%d file(s) were not downloaded." = "%d файлов не было загружено."; /* time interval */ -"%d hours" = "%d ч."; +"%d hours" = "%d час."; /* alert title */ "%d messages not forwarded" = "%d сообщений не переслано"; @@ -199,6 +178,9 @@ /* time interval */ "%d sec" = "%d сек"; +/* delete after time */ +"%d seconds(s)" = "%d секунд"; + /* integrity error chat item */ "%d skipped message(s)" = "%d пропущенных сообщение(й)"; @@ -221,7 +203,7 @@ "%lld group events" = "%lld событий"; /* No comment provided by engineer. */ -"%lld members" = "Членов группы: %lld"; +"%lld members" = "%lld членов"; /* No comment provided by engineer. */ "%lld messages blocked" = "%lld сообщений заблокировано"; @@ -241,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld новых языков интерфейса"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунд"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунд"; @@ -289,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0с"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 день"; /* time interval */ @@ -298,12 +278,17 @@ /* No comment provided by engineer. */ "1 minute" = "1 минута"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 месяц"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 неделю"; +/* delete after time */ +"1 year" = "1 год"; + /* No comment provided by engineer. */ "1-time link" = "Одноразовая ссылка"; @@ -332,7 +317,7 @@ "A separate TCP connection will be used **for each chat profile you have in the app**." = "Отдельное TCP-соединение будет использоваться **для каждого профиля чата, который Вы имеете в приложении**."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Отдельное TCP-соединение (и авторизация SOCKS) будет использоваться **для каждого контакта и члена группы**.\n**Обратите внимание**: если у Вас много контактов, потребление батареи и трафика может быть значительно выше, и некоторые соединения могут не работать."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Будет использовано отдельное TCP соединение **для каждого контакта и члена группы**.\n**Примечание**: Чем больше подключений, тем быстрее разряжается батарея и расходуется трафик, а некоторые соединения могут отваливаться."; /* No comment provided by engineer. */ "Abort" = "Прекратить"; @@ -356,10 +341,16 @@ "Accent" = "Акцент"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Принять"; +/* alert action */ +"Accept as member" = "Принять в группу"; + +/* alert action */ +"Accept as observer" = "Принять как читателя"; + /* No comment provided by engineer. */ "Accept conditions" = "Принять условия"; @@ -370,9 +361,15 @@ "Accept contact request from %@?" = "Принять запрос на соединение от %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Принять инкогнито"; +/* alert title */ +"Accept member" = "Принять члена"; + +/* rcv group event chat item */ +"accepted %@" = "принят %@"; + /* call status */ "accepted call" = "принятый звонок"; @@ -382,12 +379,18 @@ /* chat list item title */ "accepted invitation" = "принятое приглашение"; +/* rcv group event chat item */ +"accepted you" = "Вы приняты"; + /* No comment provided by engineer. */ "Acknowledged" = "Подтверждено"; /* No comment provided by engineer. */ "Acknowledgement errors" = "Ошибки подтверждения"; +/* token status text */ +"Active" = "Активный"; + /* No comment provided by engineer. */ "Active connections" = "Активные соединения"; @@ -397,6 +400,9 @@ /* No comment provided by engineer. */ "Add friends" = "Добавить друзей"; +/* No comment provided by engineer. */ +"Add list" = "Добавить список"; + /* No comment provided by engineer. */ "Add profile" = "Добавить профиль"; @@ -412,6 +418,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Добавить на другое устройство"; +/* No comment provided by engineer. */ +"Add to list" = "Добавить в список"; + /* No comment provided by engineer. */ "Add welcome message" = "Добавить приветственное сообщение"; @@ -469,12 +478,21 @@ /* chat item text */ "agreeing encryption…" = "шифрование согласовывается…"; +/* member criteria value */ +"all" = "все"; + +/* No comment provided by engineer. */ +"All" = "Все"; + /* No comment provided by engineer. */ "All app data is deleted." = "Все данные приложения будут удалены."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Все чаты и сообщения будут удалены - это нельзя отменить!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Все чаты будут удалены из списка %@, и список удален."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Все данные удаляются при его вводе."; @@ -482,7 +500,7 @@ "All data is kept private on your device." = "Все данные хранятся только на вашем устройстве."; /* No comment provided by engineer. */ -"All group members will remain connected." = "Все члены группы, которые соединились через эту ссылку, останутся в группе."; +"All group members will remain connected." = "Все члены группы останутся соединены."; /* feature role */ "all members" = "все члены"; @@ -502,6 +520,12 @@ /* profile dropdown */ "All profiles" = "Все профили"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Все сообщения о нарушениях будут заархивированы для вас."; + +/* No comment provided by engineer. */ +"All servers" = "Все серверы"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Все контакты, которые соединились через этот адрес, сохранятся."; @@ -536,7 +560,7 @@ "Allow message reactions." = "Разрешить реакции на сообщения."; /* No comment provided by engineer. */ -"Allow sending direct messages to members." = "Разрешить посылать прямые сообщения членам группы."; +"Allow sending direct messages to members." = "Разрешить личные сообщения членам группы."; /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Разрешить посылать исчезающие сообщения."; @@ -547,6 +571,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Разрешить необратимо удалять отправленные сообщения. (24 часа)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Разрешить отправлять сообщения о нарушениях модераторам."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Разрешить посылать файлы и медиа."; @@ -601,6 +628,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "и %lld других событий"; +/* report reason */ +"Another reason" = "Другая причина"; + /* No comment provided by engineer. */ "Answer call" = "Принять звонок"; @@ -616,6 +646,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Приложение шифрует новые локальные файлы (кроме видео)."; +/* No comment provided by engineer. */ +"App group:" = "Группа приложения:"; + /* No comment provided by engineer. */ "App icon" = "Иконка"; @@ -643,15 +676,36 @@ /* No comment provided by engineer. */ "Apply to" = "Применить к"; +/* No comment provided by engineer. */ +"Archive" = "Архивировать"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Архивировать %lld сообщений о нарушениях?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Архивировать все сообщения о нарушениях?"; + /* No comment provided by engineer. */ "Archive and upload" = "Архивировать и загрузить"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Архивируйте контакты чтобы продолжить переписку."; +/* No comment provided by engineer. */ +"Archive report" = "Архивировать сообщение о нарушении"; + +/* No comment provided by engineer. */ +"Archive report?" = "Архивировать сообщение о нарушении?"; + +/* swipe action */ +"Archive reports" = "Архивировать сообщения о нарушениях"; + /* No comment provided by engineer. */ "Archived contacts" = "Архивированные контакты"; +/* No comment provided by engineer. */ +"archived report" = "заархивированное сообщение о нарушении"; + /* No comment provided by engineer. */ "Archiving database" = "Подготовка архива"; @@ -730,6 +784,9 @@ /* No comment provided by engineer. */ "Better groups" = "Улучшенные группы"; +/* No comment provided by engineer. */ +"Better groups performance" = "Улучшенная производительность групп"; + /* No comment provided by engineer. */ "Better message dates." = "Улучшенные даты сообщений."; @@ -742,6 +799,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Улучшенные уведомления"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Улучшенная конфиденциальность и безопасность"; + /* No comment provided by engineer. */ "Better security ✅" = "Улучшенная безопасность ✅"; @@ -758,13 +818,13 @@ "Block for all" = "Заблокировать для всех"; /* No comment provided by engineer. */ -"Block group members" = "Блокируйте членов группы"; +"Block group members" = "Заблокировать членов группы"; /* No comment provided by engineer. */ "Block member" = "Заблокировать члена группы"; /* No comment provided by engineer. */ -"Block member for all?" = "Заблокировать члена для всех?"; +"Block member for all?" = "Заблокировать для всех?"; /* No comment provided by engineer. */ "Block member?" = "Заблокировать члена группы?"; @@ -775,7 +835,8 @@ /* rcv group event chat item */ "blocked %@" = "%@ заблокирован"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "заблокировано администратором"; /* No comment provided by engineer. */ @@ -814,9 +875,15 @@ /* No comment provided by engineer. */ "Business chats" = "Бизнес разговоры"; +/* No comment provided by engineer. */ +"Businesses" = "Бизнесы"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Используя SimpleX Chat, Вы согласны:\n- отправлять только законные сообщения в публичных группах.\n- уважать других пользователей – не отправлять спам."; + /* No comment provided by engineer. */ "call" = "звонок"; @@ -845,7 +912,7 @@ "Can't call contact" = "Не удается позвонить контакту"; /* No comment provided by engineer. */ -"Can't call member" = "Не удается позвонить члену группы"; +"Can't call member" = "Не удаётся позвонить члену группы"; /* No comment provided by engineer. */ "Can't invite contact!" = "Нельзя пригласить контакт!"; @@ -854,10 +921,13 @@ "Can't invite contacts!" = "Нельзя пригласить контакты!"; /* No comment provided by engineer. */ -"Can't message member" = "Не удается написать члену группы"; +"Can't message member" = "Не удаётся отправить сообщение члену группы"; + +/* No comment provided by engineer. */ +"can't send messages" = "нельзя отправлять"; /* alert action - alert button */ +alert button */ "Cancel" = "Отменить"; /* No comment provided by engineer. */ @@ -884,6 +954,9 @@ /* No comment provided by engineer. */ "Change" = "Поменять"; +/* alert title */ +"Change automatic message deletion?" = "Измененить автоматическое удаление сообщений?"; + /* authentication reason */ "Change chat profiles" = "Поменять профили"; @@ -912,7 +985,7 @@ "Change self-destruct mode" = "Изменить режим самоуничтожения"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Изменить код самоуничтожения"; /* chat item text */ @@ -990,9 +1063,18 @@ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Разговор будет удален для Вас - это действие нельзя отменить!"; +/* chat toolbar */ +"Chat with admins" = "Чат с админами"; + +/* No comment provided by engineer. */ +"Chat with member" = "Чат с членом группы"; + /* No comment provided by engineer. */ "Chats" = "Чаты"; +/* No comment provided by engineer. */ +"Chats with members" = "Чаты с членами группы"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Проверять сообщения каждые 20 минут."; @@ -1032,6 +1114,12 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Очистить разговор?"; +/* No comment provided by engineer. */ +"Clear group?" = "Очистить группу?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Очистить или удалить группу?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Очистить личные заметки?"; @@ -1047,6 +1135,9 @@ /* No comment provided by engineer. */ "colored" = "цвет"; +/* report reason */ +"Community guidelines violation" = "Нарушение правил группы"; + /* server test step */ "Compare file" = "Сравнение файла"; @@ -1071,12 +1162,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Условия использования"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Условия будут приняты для включенных операторов через 30 дней."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Условия будут приняты для оператора(ов): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Условия будут приняты для оператора(ов): **%@**."; @@ -1089,6 +1174,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "Настройка ICE серверов"; +/* No comment provided by engineer. */ +"Configure server operators" = "Настроить операторов серверов"; + /* No comment provided by engineer. */ "Confirm" = "Подтвердить"; @@ -1119,6 +1207,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Подтвердить загрузку"; +/* token status text */ +"Confirmed" = "Подтвержденный"; + /* server test step */ "Connect" = "Соединиться"; @@ -1218,6 +1309,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Состояние соединения и серверов."; +/* No comment provided by engineer. */ +"Connection blocked" = "Соединение заблокировано"; + /* No comment provided by engineer. */ "Connection error" = "Ошибка соединения"; @@ -1227,12 +1321,21 @@ /* chat list item title (it should not be shown */ "connection established" = "соединение установлено"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Соединение заблокировано сервером оператора:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Соединение не готово."; + /* No comment provided by engineer. */ "Connection notifications" = "Уведомления по соединениям"; /* No comment provided by engineer. */ "Connection request sent!" = "Запрос на соединение отправлен!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Соединение требует повторного согласования шифрования."; + /* No comment provided by engineer. */ "Connection security" = "Безопасность соединения"; @@ -1260,9 +1363,15 @@ /* No comment provided by engineer. */ "Contact already exists" = "Существующий контакт"; +/* No comment provided by engineer. */ +"contact deleted" = "контакт удален"; + /* No comment provided by engineer. */ "Contact deleted!" = "Контакт удален!"; +/* No comment provided by engineer. */ +"contact disabled" = "контакт выключен"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "у контакта есть e2e шифрование"; @@ -1281,6 +1390,9 @@ /* No comment provided by engineer. */ "Contact name" = "Имена контактов"; +/* No comment provided by engineer. */ +"contact not ready" = "контакт не готов"; + /* No comment provided by engineer. */ "Contact preferences" = "Предпочтения контакта"; @@ -1293,6 +1405,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Контакты могут помечать сообщения для удаления; Вы сможете просмотреть их."; +/* blocking reason */ +"Content violates conditions of use" = "Содержание нарушает условия использования"; + /* No comment provided by engineer. */ "Continue" = "Продолжить"; @@ -1335,6 +1450,9 @@ /* No comment provided by engineer. */ "Create link" = "Создать ссылку"; +/* No comment provided by engineer. */ +"Create list" = "Создать список"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Создайте новый профиль в [приложении для компьютера](https://simplex.chat/downloads/). 💻"; @@ -1476,18 +1594,18 @@ /* No comment provided by engineer. */ "decryption errors" = "ошибки расшифровки"; -/* pref value */ -"default (%@)" = "по умолчанию (%@)"; +/* delete after time +pref value */ +"default (%@)" = "базовый (%@)"; /* No comment provided by engineer. */ -"default (no)" = "по умолчанию (нет)"; +"default (no)" = "базовый (нет)"; /* No comment provided by engineer. */ -"default (yes)" = "по умолчанию (да)"; +"default (yes)" = "базовый (да)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Удалить"; /* No comment provided by engineer. */ @@ -1514,12 +1632,18 @@ /* No comment provided by engineer. */ "Delete chat" = "Удалить разговор"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Удалить сообщения с вашего устройства."; + /* No comment provided by engineer. */ "Delete chat profile" = "Удалить профиль чата"; /* No comment provided by engineer. */ "Delete chat profile?" = "Удалить профиль?"; +/* alert title */ +"Delete chat with member?" = "Удалить чат с членом группы?"; + /* No comment provided by engineer. */ "Delete chat?" = "Удалить разговор?"; @@ -1568,17 +1692,20 @@ /* No comment provided by engineer. */ "Delete link?" = "Удалить ссылку?"; +/* alert title */ +"Delete list?" = "Удалить список?"; + /* No comment provided by engineer. */ "Delete member message?" = "Удалить сообщение участника?"; /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Удалить сообщения"; /* No comment provided by engineer. */ -"Delete messages after" = "Удалять сообщения через"; +"Delete messages after" = "Удалять сообщения"; /* No comment provided by engineer. */ "Delete old database" = "Удалить предыдущую версию данных"; @@ -1598,6 +1725,9 @@ /* server test step */ "Delete queue" = "Удаление очереди"; +/* No comment provided by engineer. */ +"Delete report" = "Удалить сообщение о нарушении"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Удаляйте до 20 сообщений за раз."; @@ -1698,14 +1828,20 @@ "Direct messages" = "Прямые сообщения"; /* No comment provided by engineer. */ -"Direct messages between members are prohibited in this chat." = "Прямые сообщения между членами запрещены в этом разговоре."; +"Direct messages between members are prohibited in this chat." = "Личные сообщения запрещены в этой группе."; /* No comment provided by engineer. */ -"Direct messages between members are prohibited." = "Прямые сообщения между членами группы запрещены."; +"Direct messages between members are prohibited." = "Прямые сообщения между членами запрещены."; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Выключить (кроме исключений)"; +/* alert title */ +"Disable automatic message deletion?" = "Отключить автоматическое удаление сообщений?"; + +/* alert button */ +"Disable delete messages" = "Отключить удаление сообщений"; + /* No comment provided by engineer. */ "Disable for all" = "Выключить для всех"; @@ -1766,6 +1902,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "Не используйте SimpleX для экстренных звонков."; +/* No comment provided by engineer. */ +"Documents:" = "Документы:"; + /* No comment provided by engineer. */ "Don't create address" = "Не создавать адрес"; @@ -1773,13 +1912,19 @@ "Don't enable" = "Не включать"; /* No comment provided by engineer. */ +"Don't miss important messages." = "Не пропустите важные сообщения."; + +/* alert action */ "Don't show again" = "Не показывать"; +/* No comment provided by engineer. */ +"Done" = "Готово"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Откатить версию и открыть чат"; /* alert button - chat item action */ +chat item action */ "Download" = "Загрузить"; /* No comment provided by engineer. */ @@ -1836,14 +1981,14 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Включить (кроме исключений)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Включить автоматическое удаление сообщений?"; /* No comment provided by engineer. */ "Enable camera access" = "Включить доступ к камере"; /* No comment provided by engineer. */ -"Enable Flux" = "Включить Flux"; +"Enable Flux in Network & servers settings for better metadata privacy." = "Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных."; /* No comment provided by engineer. */ "Enable for all" = "Включить для всех"; @@ -1956,6 +2101,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "требуется новое соглашение о шифровании для %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Выполняется повторное согласование шифрования."; + /* No comment provided by engineer. */ "ended" = "завершён"; @@ -2010,6 +2158,9 @@ /* No comment provided by engineer. */ "Error accepting contact request" = "Ошибка при принятии запроса на соединение"; +/* alert title */ +"Error accepting member" = "Ошибка вступления члена группы"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Ошибка при добавлении членов группы"; @@ -2031,6 +2182,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Ошибка при смене на Инкогнито!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Ошибка проверки статуса токена"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Ошибка подключения к пересылающему серверу %@. Попробуйте позже."; @@ -2043,8 +2197,11 @@ /* No comment provided by engineer. */ "Error creating group link" = "Ошибка при создании ссылки группы"; +/* alert title */ +"Error creating list" = "Ошибка создания списка"; + /* No comment provided by engineer. */ -"Error creating member contact" = "Ошибка создания контакта с членом группы"; +"Error creating member contact" = "Ошибка при создании контакта"; /* No comment provided by engineer. */ "Error creating message" = "Ошибка создания сообщения"; @@ -2052,12 +2209,18 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Ошибка создания профиля!"; +/* No comment provided by engineer. */ +"Error creating report" = "Ошибка создания сообщения о нарушении"; + /* No comment provided by engineer. */ "Error decrypting file" = "Ошибка расшифровки файла"; /* No comment provided by engineer. */ "Error deleting chat database" = "Ошибка при удалении данных чата"; +/* alert title */ +"Error deleting chat with member" = "Ошибка при удалении чата с членом группы"; + /* No comment provided by engineer. */ "Error deleting chat!" = "Ошибка при удалении чата!"; @@ -2118,12 +2281,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Ошибка переподключения к серверам"; -/* No comment provided by engineer. */ +/* alert title */ +"Error registering for notifications" = "Ошибка регистрации для уведомлений"; + +/* alert title */ "Error removing member" = "Ошибка при удалении члена группы"; +/* alert title */ +"Error reordering lists" = "Ошибка сортировки списков"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Ошибка сброса статистики"; +/* alert title */ +"Error saving chat list" = "Ошибка сохранения списка чатов"; + /* No comment provided by engineer. */ "Error saving group profile" = "Ошибка при сохранении профиля группы"; @@ -2152,7 +2324,7 @@ "Error sending email" = "Ошибка отправки email"; /* No comment provided by engineer. */ -"Error sending member contact invitation" = "Ошибка отправки приглашения члену группы"; +"Error sending member contact invitation" = "Ошибка при отправке приглашения члену"; /* No comment provided by engineer. */ "Error sending message" = "Ошибка при отправке сообщения"; @@ -2175,6 +2347,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Ошибка синхронизации соединения"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Ошибка проверки соединения с сервером"; + /* No comment provided by engineer. */ "Error updating group link" = "Ошибка обновления ссылки группы"; @@ -2199,7 +2374,9 @@ /* No comment provided by engineer. */ "Error: " = "Ошибка: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Ошибка: %@"; /* No comment provided by engineer. */ @@ -2217,9 +2394,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Даже когда они выключены в разговоре."; -/* No comment provided by engineer. */ -"event happened" = "событие произошло"; - /* No comment provided by engineer. */ "Exit without saving" = "Выйти без сохранения"; @@ -2229,6 +2403,9 @@ /* No comment provided by engineer. */ "expired" = "истекло"; +/* token status text */ +"Expired" = "Истекший"; + /* No comment provided by engineer. */ "Export database" = "Экспорт архива чата"; @@ -2253,18 +2430,30 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Быстрые и не нужно ждать, когда отправитель онлайн!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Ускорено удаление групп."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Быстрое вступление и надежная доставка сообщений."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Ускорена отправка сообщений."; + /* swipe action */ "Favorite" = "Избранный"; /* No comment provided by engineer. */ +"Favorites" = "Избранное"; + +/* file error alert title */ "File error" = "Ошибка файла"; /* alert message */ "File errors:\n%@" = "Ошибки файлов:\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Файл заблокирован оператором сервера:\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Файл не найден - скорее всего, файл был удален или отменен."; @@ -2338,10 +2527,10 @@ "Fix not supported by contact" = "Починка не поддерживается контактом"; /* No comment provided by engineer. */ -"Fix not supported by group member" = "Починка не поддерживается членом группы"; +"Fix not supported by group member" = "Починка не поддерживается членом группы."; /* No comment provided by engineer. */ -"for better metadata privacy." = "для лучшей конфиденциальности метаданных."; +"For all moderators" = "Для всех модераторов"; /* servers error */ "For chat profile %@:" = "Для профиля чата %@:"; @@ -2352,6 +2541,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux."; +/* No comment provided by engineer. */ +"For me" = "Для меня"; + /* No comment provided by engineer. */ "For private routing" = "Для доставки сообщений"; @@ -2424,6 +2616,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Уменьшенное потребление батареи"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Уведомления, когда Вас упомянули."; + /* No comment provided by engineer. */ "GIFs and stickers" = "ГИФ файлы и стикеры"; @@ -2463,6 +2658,9 @@ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Приглашение в группу больше не действительно, оно было удалено отправителем."; +/* No comment provided by engineer. */ +"group is deleted" = "группа удалена"; + /* No comment provided by engineer. */ "Group link" = "Ссылка группы"; @@ -2496,9 +2694,15 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Группа будет удалена для Вас - это действие нельзя отменить!"; +/* No comment provided by engineer. */ +"Groups" = "Группы"; + /* No comment provided by engineer. */ "Help" = "Помощь"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Помогайте администраторам модерировать их группы."; + /* No comment provided by engineer. */ "Hidden" = "Скрытое"; @@ -2535,6 +2739,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Как это улучшает конфиденциальность"; +/* alert button */ +"How it works" = "Как это работает"; + /* No comment provided by engineer. */ "How SimpleX works" = "Как SimpleX работает"; @@ -2622,6 +2829,12 @@ /* No comment provided by engineer. */ "inactive" = "неактивен"; +/* report reason */ +"Inappropriate content" = "Неприемлемый контент"; + +/* report reason */ +"Inappropriate profile" = "Неприемлемый профиль"; + /* No comment provided by engineer. */ "Incognito" = "Инкогнито"; @@ -2688,6 +2901,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Цвета интерфейса"; +/* token status text */ +"Invalid" = "Недействительный"; + +/* token status text */ +"Invalid (bad token)" = "Недействительный (плохой токен)"; + +/* token status text */ +"Invalid (expired)" = "Недействительный (истекший)"; + +/* token status text */ +"Invalid (unregistered)" = "Недействительный (незарегистрированный)"; + +/* token status text */ +"Invalid (wrong topic)" = "Недействительный (плохой заголовок)"; + /* invalid chat data */ "invalid chat" = "ошибка чата"; @@ -2889,6 +3117,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Связанные компьютеры"; +/* swipe action */ +"List" = "Список"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Название списка и эмодзи должны быть разными для всех списков."; + +/* No comment provided by engineer. */ +"List name..." = "Имя списка..."; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2952,20 +3189,29 @@ /* profile update event chat item */ "member %@ changed to %@" = "член %1$@ изменился на %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "Приём членов в группу"; + /* rcv group event chat item */ "member connected" = "соединен(а)"; +/* No comment provided by engineer. */ +"member has old version" = "член имеет старую версию"; + /* item status text */ "Member inactive" = "Член неактивен"; +/* chat feature */ +"Member reports" = "Сообщения о нарушениях"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "Роль участника будет изменена на \"%@\". Все участники разговора получат уведомление."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". All group members will be notified." = "Роль члена группы будет изменена на \"%@\". Все члены группы получат сообщение."; +"Member role will be changed to \"%@\". All group members will be notified." = "Роль члена будет изменена на \"%@\". Все члены группы получат уведомление."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль члена группы будет изменена на \"%@\". Будет отправлено новое приглашение."; +"Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль члена будет изменена на \"%@\". Будет отправлено новое приглашение."; /* No comment provided by engineer. */ "Member will be removed from chat - this cannot be undone!" = "Член будет удален из разговора - это действие нельзя отменить!"; @@ -2973,26 +3219,35 @@ /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Член группы будет удален - это действие нельзя отменить!"; -/* No comment provided by engineer. */ -"Members can add message reactions." = "Члены группы могут добавлять реакции на сообщения."; +/* alert message */ +"Member will join the group, accept member?" = "Участник хочет присоединиться к группе. Принять?"; /* No comment provided by engineer. */ -"Members can irreversibly delete sent messages. (24 hours)" = "Члены группы могут необратимо удалять отправленные сообщения. (24 часа)"; +"Members can add message reactions." = "Члены могут добавлять реакции на сообщения."; /* No comment provided by engineer. */ -"Members can send direct messages." = "Члены группы могут посылать прямые сообщения."; +"Members can irreversibly delete sent messages. (24 hours)" = "Члены могут необратимо удалять отправленные сообщения. (24 часа)"; /* No comment provided by engineer. */ -"Members can send disappearing messages." = "Члены группы могут посылать исчезающие сообщения."; +"Members can report messsages to moderators." = "Члены группы могут пожаловаться модераторам."; /* No comment provided by engineer. */ -"Members can send files and media." = "Члены группы могут слать файлы и медиа."; +"Members can send direct messages." = "Члены могут посылать прямые сообщения."; /* No comment provided by engineer. */ -"Members can send SimpleX links." = "Члены группы могут отправлять ссылки SimpleX."; +"Members can send disappearing messages." = "Члены могут посылать исчезающие сообщения."; /* No comment provided by engineer. */ -"Members can send voice messages." = "Члены группы могут отправлять голосовые сообщения."; +"Members can send files and media." = "Члены могут слать файлы и медиа."; + +/* No comment provided by engineer. */ +"Members can send SimpleX links." = "Члены могут отправлять ссылки SimpleX."; + +/* No comment provided by engineer. */ +"Members can send voice messages." = "Члены могут отправлять голосовые сообщения."; + +/* No comment provided by engineer. */ +"Mention members 👋" = "Упоминайте участников 👋"; /* No comment provided by engineer. */ "Menus" = "Меню"; @@ -3066,6 +3321,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Сообщения от %@ будут показаны!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Сообщения в этом чате никогда не будут удалены."; + /* No comment provided by engineer. */ "Messages received" = "Получено сообщений"; @@ -3138,9 +3396,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "удалено %@"; +/* member role */ +"moderator" = "модератор"; + /* time unit */ "months" = "месяцев"; +/* swipe action */ +"More" = "Больше"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Дополнительные улучшения скоро!"; @@ -3156,12 +3420,12 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Много профилей чата"; -/* No comment provided by engineer. */ -"mute" = "без звука"; - -/* swipe action */ +/* notification label action */ "Mute" = "Без звука"; +/* notification label action */ +"Mute all" = "Все без звука"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Без звука, когда не активный!"; @@ -3192,9 +3456,12 @@ /* No comment provided by engineer. */ "Network status" = "Состояние сети"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "никогда"; +/* token status text */ +"New" = "Новый"; + /* No comment provided by engineer. */ "New chat" = "Новый чат"; @@ -3225,6 +3492,9 @@ /* No comment provided by engineer. */ "New member role" = "Роль члена группы"; +/* rcv group event chat item */ +"New member wants to join the group." = "Новый участник хочет присоединиться к группе."; + /* notification */ "new message" = "новое сообщение"; @@ -3255,6 +3525,18 @@ /* Authentication unavailable */ "No app password" = "Нет кода доступа"; +/* No comment provided by engineer. */ +"No chats" = "Нет чатов"; + +/* No comment provided by engineer. */ +"No chats found" = "Чаты не найдены"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Нет чатов в списке %@"; + +/* No comment provided by engineer. */ +"No chats with members" = "Нет чатов с членами группы"; + /* No comment provided by engineer. */ "No contacts selected" = "Контакты не выбраны"; @@ -3288,6 +3570,9 @@ /* servers error */ "No media & file servers." = "Нет серверов файлов и медиа."; +/* No comment provided by engineer. */ +"No message" = "Нет сообщения"; + /* servers error */ "No message servers." = "Нет серверов сообщений."; @@ -3324,12 +3609,24 @@ /* copied message info in history */ "no text" = "нет текста"; +/* alert title */ +"No token!" = "Нет токена!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Нет непрочитанных чатов"; + /* No comment provided by engineer. */ "No user identifiers." = "Без идентификаторов пользователей."; /* No comment provided by engineer. */ "Not compatible!" = "Несовместимая версия!"; +/* No comment provided by engineer. */ +"not synchronized" = "не синхронизирован"; + +/* No comment provided by engineer. */ +"Notes" = "Заметки"; + /* No comment provided by engineer. */ "Nothing selected" = "Ничего не выбрано"; @@ -3342,18 +3639,25 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Уведомления выключены"; +/* alert title */ +"Notifications error" = "Ошибка уведомлений"; + /* No comment provided by engineer. */ "Notifications privacy" = "Конфиденциальность уведомлений"; +/* alert title */ +"Notifications status" = "Статус уведомлений"; + /* No comment provided by engineer. */ -"Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Теперь админы могут:\n- удалять сообщения членов.\n- приостанавливать членов (роль \"наблюдатель\")"; +"Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Теперь админы могут:\n- удалять сообщения членов.\n- приостанавливать членов (роль наблюдатель)"; /* member role */ "observer" = "читатель"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "нет"; /* blur media */ @@ -3365,7 +3669,8 @@ /* feature offered item */ "offered %@: %@" = "предложил(a) %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ок"; /* No comment provided by engineer. */ @@ -3407,6 +3712,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Только владельцы группы могут разрешить голосовые сообщения."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Только отправитель и модераторы видят это"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Только вы и модераторы видят это"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Только Вы можете добавлять реакции на сообщения."; @@ -3437,7 +3748,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Только Ваш контакт может отправлять голосовые сообщения."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Открыть"; /* No comment provided by engineer. */ @@ -3455,6 +3766,9 @@ /* No comment provided by engineer. */ "Open group" = "Открыть группу"; +/* alert title */ +"Open link?" = "Открыть ссылку?"; + /* authentication reason */ "Open migration to another device" = "Открытие миграции на другое устройство"; @@ -3483,11 +3797,14 @@ "Or securely share this file link" = "Или передайте эту ссылку"; /* No comment provided by engineer. */ -"Or show this code" = "Или покажите этот код"; +"Or show this code" = "Или покажите код"; /* No comment provided by engineer. */ "Or to share privately" = "Или поделиться конфиденциально"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Организуйте чаты в списки"; + /* No comment provided by engineer. */ "other" = "другое"; @@ -3546,7 +3863,16 @@ "peer-to-peer" = "peer-to-peer"; /* No comment provided by engineer. */ -"Pending" = "В ожидании"; +"pending" = "ожидает"; + +/* No comment provided by engineer. */ +"Pending" = "Ожидает"; + +/* No comment provided by engineer. */ +"pending approval" = "ожидает утверждения"; + +/* No comment provided by engineer. */ +"pending review" = "ожидает одобрения"; /* No comment provided by engineer. */ "Periodic" = "Периодически"; @@ -3614,6 +3940,18 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете."; +/* token info */ +"Please try to disable and re-enable notfications." = "Попробуйте выключить и снова включить уведомления."; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Пожалуйста, подождите, пока модераторы группы рассмотрят ваш запрос на вступление."; + +/* token info */ +"Please wait for token activation to complete." = "Пожалуйста, дождитесь завершения активации токена."; + +/* token info */ +"Please wait for token to be registered." = "Пожалуйста, дождитесь регистрации токена."; + /* No comment provided by engineer. */ "Polish interface" = "Польский интерфейс"; @@ -3644,12 +3982,21 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Конфиденциальность для ваших покупателей."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Политика конфиденциальности и условия использования."; + /* No comment provided by engineer. */ "Privacy redefined" = "Более конфиденциальный"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Частные разговоры, группы и Ваши контакты недоступны для операторов серверов."; + /* No comment provided by engineer. */ "Private filenames" = "Защищенные имена файлов"; +/* No comment provided by engineer. */ +"Private media file names." = "Конфиденциальные названия медиафайлов."; + /* No comment provided by engineer. */ "Private message routing" = "Конфиденциальная доставка сообщений"; @@ -3695,6 +4042,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Запретить реакции на сообщения."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Запретить жаловаться модераторам группы."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Запретить посылать прямые сообщения членам группы."; @@ -3857,8 +4207,17 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Уменьшенное потребление батареи"; +/* No comment provided by engineer. */ +"Register" = "Зарегистрировать"; + +/* token info */ +"Register notification token?" = "Зарегистрировать токен уведомлений?"; + +/* token status text */ +"Registered" = "Зарегистрирован"; + /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Отклонить"; /* No comment provided by engineer. */ @@ -3867,6 +4226,12 @@ /* No comment provided by engineer. */ "Reject contact request" = "Отклонить запрос"; +/* alert title */ +"Reject member?" = "Отклонить участника?"; + +/* No comment provided by engineer. */ +"rejected" = "отклонён"; + /* call status */ "rejected call" = "отклонённый звонок"; @@ -3903,6 +4268,9 @@ /* profile update event chat item */ "removed contact address" = "удалён адрес контакта"; +/* No comment provided by engineer. */ +"removed from group" = "удален из группы"; + /* profile update event chat item */ "removed profile picture" = "удалена картинка профиля"; @@ -3936,6 +4304,42 @@ /* chat item action */ "Reply" = "Ответить"; +/* chat item action */ +"Report" = "Пожаловаться"; + +/* report reason */ +"Report content: only group moderators will see it." = "Пожаловаться на сообщение: увидят только модераторы группы."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Пожаловаться на профиль: увидят только модераторы группы."; + +/* report reason */ +"Report other: only group moderators will see it." = "Пожаловаться: увидят только модераторы группы."; + +/* No comment provided by engineer. */ +"Report reason?" = "Причина сообщения?"; + +/* alert title */ +"Report sent to moderators" = "Жалоба отправлена модераторам"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Пожаловаться на спам: увидят только модераторы группы."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Пожаловаться на нарушение: увидят только модераторы группы."; + +/* report in notification */ +"Report: %@" = "Сообщение о нарушении: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Сообщения о нарушениях запрещены в этой группе."; + +/* No comment provided by engineer. */ +"Reports" = "Сообщения о нарушениях"; + +/* No comment provided by engineer. */ +"request to join rejected" = "запрос на вступление отклонён"; + /* chat list item title */ "requested to connect" = "запрошено соединение"; @@ -3991,10 +4395,19 @@ "Reveal" = "Показать"; /* No comment provided by engineer. */ -"Review conditions" = "Посмотреть условия"; +"review" = "рассмотрение"; /* No comment provided by engineer. */ -"Review later" = "Посмотреть позже"; +"Review conditions" = "Посмотреть условия"; + +/* admission stage */ +"Review members" = "Одобрять членов"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Одобрять членов для вступления в группу."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "одобрен админами"; /* No comment provided by engineer. */ "Revoke" = "Отозвать"; @@ -4018,12 +4431,15 @@ "Safer groups" = "Более безопасные группы"; /* alert button - chat item action */ +chat item action */ "Save" = "Сохранить"; /* alert button */ "Save (and notify contacts)" = "Сохранить (и уведомить контакты)"; +/* alert title */ +"Save admission settings?" = "Сохранить настройки вступления?"; + /* alert button */ "Save and notify contact" = "Сохранить и уведомить контакт"; @@ -4039,6 +4455,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Сохранить профиль группы"; +/* No comment provided by engineer. */ +"Save list" = "Сохранить список"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Сохранить пароль и открыть чат"; @@ -4207,11 +4626,14 @@ /* No comment provided by engineer. */ "Send notifications" = "Отправлять уведомления"; +/* No comment provided by engineer. */ +"Send private reports" = "Вы можете сообщить о нарушениях"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Отправьте вопросы и идеи"; /* No comment provided by engineer. */ -"Send receipts" = "Отправлять отчёты о доставке"; +"Send receipts" = "Отчёты о доставке"; /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Отправьте из галереи или из дополнительных клавиатур."; @@ -4339,6 +4761,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Установить 1 день"; +/* No comment provided by engineer. */ +"Set chat name…" = "Имя чата…"; + /* No comment provided by engineer. */ "Set contact name…" = "Имя контакта…"; @@ -4351,6 +4776,12 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Установите код вместо системной аутентификации."; +/* No comment provided by engineer. */ +"Set member admission" = "Приём членов в группу"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Установите срок хранения сообщений в чатах."; + /* profile update event chat item */ "set new contact address" = "установлен новый адрес контакта"; @@ -4382,7 +4813,7 @@ "Shape profile images" = "Форма картинок профилей"; /* alert action - chat item action */ +chat item action */ "Share" = "Поделиться"; /* No comment provided by engineer. */ @@ -4413,7 +4844,7 @@ "Share SimpleX address on social media." = "Поделитесь SimpleX адресом в социальных сетях."; /* No comment provided by engineer. */ -"Share this 1-time invite link" = "Поделиться одноразовой ссылкой-приглашением"; +"Share this 1-time invite link" = "Поделитесь одноразовой ссылкой"; /* No comment provided by engineer. */ "Share to SimpleX" = "Поделиться в SimpleX"; @@ -4421,6 +4852,9 @@ /* No comment provided by engineer. */ "Share with contacts" = "Поделиться с контактами"; +/* No comment provided by engineer. */ +"Short link" = "Короткая ссылка"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Показать → на сообщениях доставленных конфиденциально."; @@ -4463,6 +4897,9 @@ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "Адрес SimpleX или одноразовая ссылка?"; +/* simplex link type */ +"SimpleX channel link" = "SimpleX ссылка канала"; + /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat и Flux заключили соглашение добавить серверы под управлением Flux в приложение."; @@ -4547,6 +4984,10 @@ /* notification title */ "Somebody" = "Контакт"; +/* blocking reason +report reason */ +"Spam" = "Спам"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Квадрат, круг и все, что между ними."; @@ -4604,6 +5045,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Остановка чата"; +/* No comment provided by engineer. */ +"Storage" = "Хранилище"; + /* No comment provided by engineer. */ "strike" = "зачеркнуть"; @@ -4673,6 +5117,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Таймаут TCP соединения"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-порт для отправки сообщений"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4682,12 +5129,15 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Временная ошибка файла"; /* server test failure */ "Test failed at step %@." = "Ошибка теста на шаге %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Протестировать уведомления"; + /* No comment provided by engineer. */ "Test server" = "Тестировать сервер"; @@ -4760,15 +5210,9 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Предыдущая версия данных чата не удалена при перемещении, её можно удалить."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Профиль отправляется только Вашим контактам."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Те же самые условия будут приняты для оператора **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Те же самые условия будут приняты для оператора(ов): **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Второй оператор серверов в приложении!"; @@ -4808,6 +5252,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Это действие нельзя отменить - сообщения в этом чате, отправленные или полученные раньше чем выбрано, будут удалены."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны."; @@ -4827,7 +5274,7 @@ "This display name is invalid. Please choose another name." = "Ошибка имени профиля. Пожалуйста, выберите другое имя."; /* No comment provided by engineer. */ -"This group has over %lld members, delivery receipts are not sent." = "В группе более %lld членов, отчёты о доставке выключены."; +"This group has over %lld members, delivery receipts are not sent." = "В этой группе более %lld членов, отчёты о доставке не отправляются."; /* No comment provided by engineer. */ "This group no longer exists." = "Эта группа больше не существует."; @@ -4838,9 +5285,15 @@ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Это ваш собственный адрес SimpleX!"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Эта ссылка была использована на другом мобильном, пожалуйста, создайте новую ссылку на компьютере."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Это сообщение было удалено или еще не получено."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**."; @@ -4907,6 +5360,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Установите режим Инкогнито при соединении."; +/* token status */ +"Token status: %@." = "Статус токена: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Прозрачность тулбара"; @@ -5015,10 +5471,7 @@ /* authentication reason */ "Unlock app" = "Разблокировать"; -/* No comment provided by engineer. */ -"unmute" = "уведомлять"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Уведомлять"; /* No comment provided by engineer. */ @@ -5027,6 +5480,9 @@ /* swipe action */ "Unread" = "Не прочитано"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Ссылка не поддерживается"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "До 100 последних сообщений отправляются новым членам."; @@ -5042,6 +5498,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Обновить настройки?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Обновленные условия"; + /* rcv group event chat item */ "updated group profile" = "обновил(а) профиль группы"; @@ -5117,18 +5576,30 @@ /* No comment provided by engineer. */ "Use servers" = "Использовать серверы"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Короткие ссылки (БЕТА)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; /* No comment provided by engineer. */ "Use SOCKS proxy" = "Использовать SOCKS прокси"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Использовать TCP-порт %@, когда порт не указан."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Использовать TCP-порт 443 только для серверов по умолчанию."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Используйте приложение во время звонка."; /* No comment provided by engineer. */ "Use the app with one hand." = "Используйте приложение одной рукой."; +/* No comment provided by engineer. */ +"Use web port" = "Использовать веб-порт"; + /* No comment provided by engineer. */ "User selection" = "Выбор пользователя"; @@ -5348,6 +5819,9 @@ /* No comment provided by engineer. */ "You accepted connection" = "Вы приняли приглашение соединиться"; +/* snd group event chat item */ +"you accepted this member" = "Вы приняли этого члена"; + /* No comment provided by engineer. */ "You allow" = "Вы разрешаете"; @@ -5405,9 +5879,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Вы можете изменить это в настройках Интерфейса."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Вы можете настроить операторов в настройках Сети и серверов."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Вы можете настроить серверы позже."; @@ -5462,6 +5933,9 @@ /* alert message */ "You can view invitation link again in connection details." = "Вы можете увидеть ссылку-приглашение снова открыв соединение."; +/* alert message */ +"You can view your reports in Chat with admins." = "Вы можете найти Ваши жалобы в Чате с админами."; + /* No comment provided by engineer. */ "You can't send messages!" = "Вы не можете отправлять сообщения!"; @@ -5534,6 +6008,9 @@ /* chat list item description */ "you shared one-time link incognito" = "Вы создали ссылку инкогнито"; +/* token info */ +"You should receive notifications." = "Вы должны получать уведомления."; + /* snd group event chat item */ "you unblocked %@" = "Вы разблокировали %@"; @@ -5627,15 +6104,15 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Будет отправлен Ваш профиль **%@**."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Ваш профиль храниться на Вашем устройстве и отправляется только контактам."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ваш профиль был изменен. Если вы сохраните его, обновленный профиль будет отправлен всем вашим контактам."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве."; - /* No comment provided by engineer. */ "Your random profile" = "Случайный профиль"; @@ -5650,7 +6127,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Ваш адрес SimpleX"; - -/* No comment provided by engineer. */ -"Your SMP servers" = "Ваши SMP серверы"; - diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 4fdc49139a..a6ef88d0d4 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (สามารถคัดลอกได้)"; @@ -16,24 +10,9 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- ข้อความเสียงนานสุด 5 นาที\n- เวลาที่กำหนดเองที่จะหายไป\n- ประวัติการแก้ไข"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 มีสี!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[มีส่วนร่วม](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -142,9 +121,6 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld นาที"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld วินาที"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld วินาที"; @@ -187,7 +163,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 วัน"; /* time interval */ @@ -196,10 +173,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 นาที"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 เดือน"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 สัปดาห์"; /* No comment provided by engineer. */ @@ -239,15 +218,15 @@ "above, then choose:" = "ด้านบน จากนั้นเลือก:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "รับ"; /* notification body */ "Accept contact request from %@?" = "รับการขอติดต่อจาก %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "ยอมรับโหมดไม่ระบุตัวตน"; /* call status */ @@ -506,7 +485,7 @@ "Can't invite contacts!" = "ไม่สามารถเชิญผู้ติดต่อได้!"; /* alert action - alert button */ +alert button */ "Cancel" = "ยกเลิก"; /* feature offered item */ @@ -546,7 +525,7 @@ "Change self-destruct mode" = "เปลี่ยนโหมดทําลายตัวเอง"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "เปลี่ยนรหัสผ่านแบบทำลายตัวเอง"; /* chat item text */ @@ -855,7 +834,8 @@ /* message decrypt error item */ "Decryption error" = "ข้อผิดพลาดในการ decrypt"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "ค่าเริ่มต้น (%@)"; /* No comment provided by engineer. */ @@ -865,8 +845,7 @@ "default (yes)" = "ค่าเริ่มต้น (ใช่)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "ลบ"; /* No comment provided by engineer. */ @@ -932,7 +911,7 @@ /* No comment provided by engineer. */ "Delete message?" = "ลบข้อความ?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "ลบข้อความ"; /* No comment provided by engineer. */ @@ -1049,7 +1028,7 @@ /* No comment provided by engineer. */ "Don't enable" = "อย่าเปิดใช้งาน"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "ไม่ต้องแสดงอีก"; /* No comment provided by engineer. */ @@ -1082,7 +1061,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "เปิดใช้งาน (เก็บการแทนที่)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "เปิดใช้งานการลบข้อความอัตโนมัติ?"; /* No comment provided by engineer. */ @@ -1277,7 +1256,7 @@ /* alert title */ "Error receiving file" = "เกิดข้อผิดพลาดในการรับไฟล์"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "เกิดข้อผิดพลาดในการลบสมาชิก"; /* No comment provided by engineer. */ @@ -1331,7 +1310,9 @@ /* No comment provided by engineer. */ "Error: " = "ผิดพลาด: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "ข้อผิดพลาด: % @"; /* No comment provided by engineer. */ @@ -1928,7 +1909,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "โปรไฟล์การแชทหลายรายการ"; -/* swipe action */ +/* notification label action */ "Mute" = "ปิดเสียง"; /* No comment provided by engineer. */ @@ -1946,7 +1927,7 @@ /* No comment provided by engineer. */ "Network status" = "สถานะเครือข่าย"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ไม่เคย"; /* notification */ @@ -2034,8 +2015,9 @@ "observer" = "ผู้สังเกตการณ์"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "ปิด"; /* blur media */ @@ -2047,7 +2029,8 @@ /* feature offered item */ "offered %@: %@" = "เสนอแล้ว %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "ตกลง"; /* No comment provided by engineer. */ @@ -2336,7 +2319,7 @@ "Reduced battery usage" = "ลดการใช้แบตเตอรี่"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "ปฏิเสธ"; /* No comment provided by engineer. */ @@ -2433,7 +2416,7 @@ "Run chat" = "เรียกใช้แชท"; /* alert button - chat item action */ +chat item action */ "Save" = "บันทึก"; /* alert button */ @@ -2635,7 +2618,7 @@ "Settings" = "การตั้งค่า"; /* alert action - chat item action */ +chat item action */ "Share" = "แชร์"; /* No comment provided by engineer. */ @@ -2848,9 +2831,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "ฐานข้อมูลเก่าไม่ได้ถูกลบในระหว่างการย้ายข้อมูล แต่สามารถลบได้"; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น"; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "ขีดที่สองที่เราพลาด! ✅"; @@ -2971,7 +2951,7 @@ /* authentication reason */ "Unlock app" = "ปลดล็อคแอป"; -/* swipe action */ +/* notification label action */ "Unmute" = "เปิดเสียง"; /* swipe action */ @@ -3311,10 +3291,10 @@ "Your privacy" = "ความเป็นส่วนตัวของคุณ"; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "โปรไฟล์ของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณและแชร์กับผู้ติดต่อของคุณเท่านั้น เซิร์ฟเวอร์ SimpleX ไม่สามารถดูโปรไฟล์ของคุณได้"; +"Your profile is stored on your device and only shared with your contacts." = "โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น"; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "โปรไฟล์ รายชื่อผู้ติดต่อ และข้อความที่ส่งของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณ"; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "โปรไฟล์ของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณและแชร์กับผู้ติดต่อของคุณเท่านั้น เซิร์ฟเวอร์ SimpleX ไม่สามารถดูโปรไฟล์ของคุณได้"; /* No comment provided by engineer. */ "Your random profile" = "โปรไฟล์แบบสุ่มของคุณ"; @@ -3328,6 +3308,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "ที่อยู่ SimpleX ของคุณ"; -/* No comment provided by engineer. */ -"Your SMP servers" = "เซิร์ฟเวอร์ SMP ของคุณ"; - diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index b3eb5d426a..3d44c895ec 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (kopyalanabilir)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 dakikaya kadar süren sesli mesajlar.\n- mesaj kaybolması için özel zaman.\n- düzenleme geçmişi."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 renklendirilmiş!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(yeni)"; /* No comment provided by engineer. */ "(this device v%@)" = "(bu cihaz v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Katkıda bulun](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -82,6 +61,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Önerilen**: cihaz tokeni ve bildirimler SimpleX Chat bildirim sunucularına gönderilir, ama mesajın içeriği, boyutu veya kimden geldiği gönderilmez."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "edindiğiniz bağlantı aracılığıyla bağlanmak için **Linki tarayın/yapıştırın**."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Dikkat**: Anında iletilen bildirimlere Anahtar Zinciri'nde kaydedilmiş parola gereklidir."; @@ -142,6 +124,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ onaylandı"; +/* No comment provided by engineer. */ +"%@ server" = "%@ sunucu"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ sunucular"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ yüklendi"; @@ -232,9 +220,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld yeni arayüz dilleri"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld saniye"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld saniye"; @@ -280,7 +265,8 @@ /* No comment provided by engineer. */ "0s" = "0sn"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 gün"; /* time interval */ @@ -289,12 +275,20 @@ /* No comment provided by engineer. */ "1 minute" = "1 dakika"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 ay"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 hafta"; +/* No comment provided by engineer. */ +"1-time link" = "tek kullanımlık bağlantı"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Tek kullanımlık bağlantı *sadece bir kişi ile* kullanılabilir - kişiyle veya uygulama içinden paylaş."; + /* No comment provided by engineer. */ "5 minutes" = "5 dakika"; @@ -328,6 +322,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Adres değişimi iptal edilsin mi?"; +/* No comment provided by engineer. */ +"About operators" = "Operatörler hakkında"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "SimpleX Chat hakkında"; @@ -338,10 +335,13 @@ "Accent" = "Ana renk"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Kabul et"; +/* No comment provided by engineer. */ +"Accept conditions" = "Koşulları kabul et"; + /* No comment provided by engineer. */ "Accept connection request?" = "Bağlantı isteği kabul edilsin mi?"; @@ -349,12 +349,15 @@ "Accept contact request from %@?" = "%@ 'den gelen iletişim isteği kabul edilsin mi?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Takma adla kabul et"; /* call status */ "accepted call" = "kabul edilen arama"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Kabul edilmiş koşullar"; + /* No comment provided by engineer. */ "Acknowledged" = "Onaylandı"; @@ -367,6 +370,9 @@ /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Kişilerinizin başkalarıyla paylaşabilmesi için profilinize adres ekleyin. Profil güncellemesi kişilerinize gönderilecek."; +/* No comment provided by engineer. */ +"Add friends" = "Arkadaş ekle"; + /* No comment provided by engineer. */ "Add profile" = "Profil ekle"; @@ -376,12 +382,24 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Karekod taratarak sunucuları ekleyin."; +/* No comment provided by engineer. */ +"Add team members" = "Takım üyesi ekle"; + /* No comment provided by engineer. */ "Add to another device" = "Başka bir cihaza ekle"; /* No comment provided by engineer. */ "Add welcome message" = "Karşılama mesajı ekleyin"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Takım üyelerini konuşmalara ekle."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "medya ve dosya sunucuları eklendi"; + +/* No comment provided by engineer. */ +"Added message servers" = "Mesaj sunucuları eklendi"; + /* No comment provided by engineer. */ "Additional accent" = "Ek ana renk"; @@ -397,6 +415,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Adres değişikliği iptal edilecek. Eski alıcı adresi kullanılacaktır."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "adres mi yoksa tek kullanımlık bağlantı mı?"; + +/* No comment provided by engineer. */ +"Address settings" = "Adres seçenekleri"; + /* member role */ "admin" = "yönetici"; @@ -439,6 +463,9 @@ /* feature role */ "all members" = "bütün üyeler"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Tüm mesajlar silinecektir - bu geri alınamaz!"; @@ -724,7 +751,8 @@ /* rcv group event chat item */ "blocked %@" = "engellendi %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "yönetici tarafından engellendi"; /* No comment provided by engineer. */ @@ -757,6 +785,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarca, Fince, Tayca ve Ukraynaca - kullanıcılara ve [Weblate] e teşekkürler! (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "İş adresi"; + +/* No comment provided by engineer. */ +"Business chats" = "İş konuşmaları"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Sohbet profiline göre (varsayılan) veya [bağlantıya göre](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -800,7 +834,7 @@ "Can't message member" = "Üyeye mesaj gönderilemiyor"; /* alert action - alert button */ +alert button */ "Cancel" = "İptal et"; /* No comment provided by engineer. */ @@ -827,6 +861,9 @@ /* No comment provided by engineer. */ "Change" = "Değiştir"; +/* authentication reason */ +"Change chat profiles" = "Sohbet profillerini değiştir"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Veritabanı parolasını değiştir?"; @@ -852,7 +889,7 @@ "Change self-destruct mode" = "Kendini yok etme modunu değiştir"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Kendini yok eden parolayı değiştir"; /* chat item text */ @@ -870,6 +907,15 @@ /* chat item text */ "changing address…" = "adres değiştiriliyor…"; +/* No comment provided by engineer. */ +"Chat" = "Sohbet"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Sohbet zaten mevcut"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Sohbet zaten mevcut!"; + /* No comment provided by engineer. */ "Chat colors" = "Sohbet renkleri"; @@ -915,9 +961,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Sohbet teması"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Sohbet bütün üyeler için silinecek - bu geri alınamaz!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Sohbet senden silinecek - bu geri alınamaz!"; + /* No comment provided by engineer. */ "Chats" = "Sohbetler"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Her 20 dakikada mesajları kontrol et."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "İzin verildiğinde mesajları kontrol et."; + /* alert title */ "Check server address and try again." = "Sunucu adresini kontrol edip tekrar deneyin."; @@ -978,6 +1036,27 @@ /* No comment provided by engineer. */ "Completed" = "Tamamlandı"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Şuradaki koşullar kabul edildi: %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Koşullar operatör(ler) için kabul edildi: **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Kullanım koşulları"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Koşullar bu operatör(ler) için kabul edilecektir: **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Koşullar şu tarihte kabul edilecektir: %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Koşullar etkin operatörler için şu tarihte otomatik olarak kabul edilecektir: %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "ICE sunucularını ayarla"; @@ -1125,6 +1204,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Bağlantı daveti gönderildi!"; +/* No comment provided by engineer. */ +"Connection security" = "Bağlantı güvenliği"; + /* No comment provided by engineer. */ "Connection terminated" = "Bağlantı sonlandırılmış"; @@ -1206,6 +1288,9 @@ /* No comment provided by engineer. */ "Create" = "Oluştur"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Tek kullanımlık bağlantı oluştur"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Rasgele profil kullanarak grup oluştur."; @@ -1257,6 +1342,9 @@ /* No comment provided by engineer. */ "creator" = "oluşturan"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Şu anki koşulların yazısı yüklenemiyor, bu bağlantıdan koşullara inceleyebilirsin:"; + /* No comment provided by engineer. */ "Current Passcode" = "Şu anki şifre"; @@ -1359,7 +1447,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Şifre çözme hataları"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "varsayılan (%@)"; /* No comment provided by engineer. */ @@ -1369,8 +1458,7 @@ "default (yes)" = "varsayılan (evet)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Sil"; /* No comment provided by engineer. */ @@ -1394,12 +1482,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Sil ve kişiye bildir"; +/* No comment provided by engineer. */ +"Delete chat" = "Sohbeti sil"; + /* No comment provided by engineer. */ "Delete chat profile" = "Sohbet profilini sil"; /* No comment provided by engineer. */ "Delete chat profile?" = "Sohbet profili silinsin mi?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Sohbet silinsin mi?"; + /* No comment provided by engineer. */ "Delete connection" = "Bağlantıyı sil"; @@ -1451,7 +1545,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Mesaj silinsin mi?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Mesajları sil"; /* No comment provided by engineer. */ @@ -1571,6 +1665,9 @@ /* chat feature */ "Direct messages" = "Doğrudan mesajlar"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "Üyeler arası doğrudan mesajlar bu sohbette yasaktır."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Bu grupta üyeler arasında direkt mesajlaşma yasaktır."; @@ -1643,14 +1740,14 @@ /* No comment provided by engineer. */ "Don't enable" = "Etkinleştirme"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Yeniden gösterme"; /* No comment provided by engineer. */ "Downgrade and open chat" = "Sürüm düşür ve sohbeti aç"; /* alert button - chat item action */ +chat item action */ "Download" = "İndir"; /* No comment provided by engineer. */ @@ -1692,6 +1789,9 @@ /* No comment provided by engineer. */ "e2e encrypted" = "uçtan uca şifrelenmiş"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Uçtan uca şifrelenmiş bildirimler."; + /* chat item action */ "Edit" = "Düzenle"; @@ -1704,7 +1804,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Etkinleştir (geçersiz kılmaları koru)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Otomatik mesaj silme etkinleştirilsin mi?"; /* No comment provided by engineer. */ @@ -1869,12 +1969,18 @@ /* No comment provided by engineer. */ "Error aborting address change" = "Adres değişikliği iptal edilirken hata oluştu"; +/* alert title */ +"Error accepting conditions" = "Koşulları kabul ederken hata oluştu"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Bağlantı isteği kabul edilirken hata oluştu"; /* No comment provided by engineer. */ "Error adding member(s)" = "Üye(ler) eklenirken hata oluştu"; +/* alert title */ +"Error adding server" = "Sunucu eklenirken hata oluştu"; + /* No comment provided by engineer. */ "Error changing address" = "Adres değiştirilirken hata oluştu"; @@ -1959,6 +2065,9 @@ /* No comment provided by engineer. */ "Error joining group" = "Gruba katılırken hata oluştu"; +/* alert title */ +"Error loading servers" = "Sunucular yüklenirken hata oluştu"; + /* No comment provided by engineer. */ "Error migrating settings" = "Ayarlar taşınırken hata oluştu"; @@ -1974,7 +2083,7 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Hata sunuculara yeniden bağlanılıyor"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Kişiyi silerken sorun oluştu"; /* No comment provided by engineer. */ @@ -1992,6 +2101,9 @@ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Parolayı Anahtar Zincirine kaydederken hata oluştu"; +/* alert title */ +"Error saving servers" = "Sunucular kaydedilirken hata oluştu"; + /* when migrating */ "Error saving settings" = "Ayarlar kaydedilirken hata oluştu"; @@ -2034,6 +2146,9 @@ /* No comment provided by engineer. */ "Error updating message" = "Mesaj güncellenirken hata oluştu"; +/* alert title */ +"Error updating server" = "Sunucu güncellenirken hata oluştu"; + /* No comment provided by engineer. */ "Error updating settings" = "Ayarları güncellerken hata oluştu"; @@ -2049,7 +2164,9 @@ /* No comment provided by engineer. */ "Error: " = "Hata: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Hata: %@"; /* No comment provided by engineer. */ @@ -2061,11 +2178,11 @@ /* No comment provided by engineer. */ "Errors" = "Hatalar"; -/* No comment provided by engineer. */ -"Even when disabled in the conversation." = "Konuşma sırasında devre dışı bırakılsa bile."; +/* servers error */ +"Errors in servers configuration." = "Sunucular yapılandırılırken hatalar oluştu."; /* No comment provided by engineer. */ -"event happened" = "etkinlik yaşandı"; +"Even when disabled in the conversation." = "Konuşma sırasında devre dışı bırakılsa bile."; /* No comment provided by engineer. */ "Exit without saving" = "Kaydetmeden çık"; @@ -2106,7 +2223,7 @@ /* swipe action */ "Favorite" = "Favori"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Dosya hatası"; /* alert message */ @@ -2187,9 +2304,21 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Düzeltme grup üyesi tarafından desteklenmiyor"; +/* servers error */ +"For chat profile %@:" = "Sohbet profili için %@:"; + /* No comment provided by engineer. */ "For console" = "Konsol için"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir."; + +/* No comment provided by engineer. */ +"For private routing" = "Gizli yönlendirme için"; + +/* No comment provided by engineer. */ +"For social media" = "Sosyal medya için"; + /* chat item action */ "Forward" = "İlet"; @@ -2361,6 +2490,12 @@ /* time unit */ "hours" = "saat"; +/* No comment provided by engineer. */ +"How it affects privacy" = "Gizliliğinizi nasıl etkiler"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Gizliliğinizi nasıl arttırır"; + /* No comment provided by engineer. */ "How SimpleX works" = "SimpleX nasıl çalışır"; @@ -2565,6 +2700,9 @@ /* No comment provided by engineer. */ "Invite members" = "Üyeleri davet et"; +/* No comment provided by engineer. */ +"Invite to chat" = "Sohbete davet et"; + /* No comment provided by engineer. */ "Invite to group" = "Gruba davet et"; @@ -2679,6 +2817,12 @@ /* swipe action */ "Leave" = "Ayrıl"; +/* No comment provided by engineer. */ +"Leave chat" = "Sohbetten ayrıl"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Sohbetten ayrılsın mı?"; + /* No comment provided by engineer. */ "Leave group" = "Gruptan ayrıl"; @@ -2775,6 +2919,9 @@ /* item status text */ "Member inactive" = "Üye inaktif"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Üye rolü \"%@\" olarak değiştirilecektir. Tüm sohbet üyeleri bilgilendirilecektir."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Üye rolü \"%@\" olarak değiştirilecektir. Ve tüm grup üyeleri bilgilendirilecektir."; @@ -2964,10 +3111,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Çoklu sohbet profili"; -/* No comment provided by engineer. */ -"mute" = "Sessiz"; - -/* swipe action */ +/* notification label action */ "Mute" = "Sustur"; /* No comment provided by engineer. */ @@ -2994,7 +3138,7 @@ /* No comment provided by engineer. */ "Network status" = "Ağ durumu"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "asla"; /* No comment provided by engineer. */ @@ -3127,8 +3271,9 @@ "observer" = "gözlemci"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "kapalı"; /* blur media */ @@ -3140,7 +3285,8 @@ /* feature offered item */ "offered %@: %@" = "%1$@: %2$@ teklif etti"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Tamam"; /* No comment provided by engineer. */ @@ -3209,7 +3355,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sadece karşıdaki kişi sesli mesajlar gönderebilir."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Aç"; /* No comment provided by engineer. */ @@ -3606,7 +3752,7 @@ "Reduced battery usage" = "Azaltılmış pil kullanımı"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Reddet"; /* No comment provided by engineer. */ @@ -3757,7 +3903,7 @@ "Safer groups" = "Daha güvenli gruplar"; /* alert button - chat item action */ +chat item action */ "Save" = "Kaydet"; /* alert button */ @@ -4109,7 +4255,7 @@ "Shape profile images" = "Profil resimlerini şekillendir"; /* alert action - chat item action */ +chat item action */ "Share" = "Paylaş"; /* No comment provided by engineer. */ @@ -4385,7 +4531,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_TVLDEKAL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Geçici dosya hatası"; /* server test failure */ @@ -4457,9 +4603,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Eski veritabanı geçiş sırasında kaldırılmadı, silinebilir."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Profil sadece kişilerinle paylaşılacak."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Özlediğimiz ikinci tik! ✅"; @@ -4682,10 +4825,7 @@ /* authentication reason */ "Unlock app" = "Uygulama kilidini aç"; -/* No comment provided by engineer. */ -"unmute" = "susturmayı kaldır"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Susturmayı kaldır"; /* No comment provided by engineer. */ @@ -5258,15 +5398,15 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Profiliniz **%@** paylaşılacaktır."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Profil sadece kişilerinle paylaşılacak."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiliniz cihazınızda saklanır ve sadece kişilerinizle paylaşılır. SimpleX sunucuları profilinizi göremez."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Profiliniz değiştirildi. Kaydederseniz, güncellenmiş profil tüm kişilerinize gönderilecektir."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Profiliniz, kişileriniz ve gönderilmiş mesajlar cihazınızda saklanır."; - /* No comment provided by engineer. */ "Your random profile" = "Rasgele profiliniz"; @@ -5279,6 +5419,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "SimpleX adresin"; -/* No comment provided by engineer. */ -"Your SMP servers" = "SMP sunucularınız"; - diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index ce8184272d..932c29d368 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (можна скопіювати)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- голосові повідомлення до 5 хвилин.\n- користувальницький час зникнення.\n- історія редагування."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 кольоровий!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(новий)"; /* No comment provided by engineer. */ "(this device v%@)" = "(цей пристрій v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внесок](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -241,9 +220,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld нові мови інтерфейсу"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунд(и)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунд"; @@ -289,7 +265,8 @@ /* No comment provided by engineer. */ "0s" = "0с"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1 день"; /* time interval */ @@ -298,10 +275,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 хвилина"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1 місяць"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1 тиждень"; /* No comment provided by engineer. */ @@ -343,6 +322,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Скасувати зміну адреси?"; +/* No comment provided by engineer. */ +"About operators" = "Про операторів"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "Про чат SimpleX"; @@ -353,8 +335,8 @@ "Accent" = "Акцент"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Прийняти"; /* No comment provided by engineer. */ @@ -367,7 +349,7 @@ "Accept contact request from %@?" = "Прийняти запит на контакт від %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Прийняти інкогніто"; /* call status */ @@ -376,6 +358,9 @@ /* No comment provided by engineer. */ "Accepted conditions" = "Прийняті умови"; +/* chat list item title */ +"accepted invitation" = "прийняте запрошення"; + /* No comment provided by engineer. */ "Acknowledged" = "Визнано"; @@ -388,6 +373,9 @@ /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Повідомлення про оновлення профілю буде надіслано вашим контактам."; +/* No comment provided by engineer. */ +"Add friends" = "Додайте друзів"; + /* No comment provided by engineer. */ "Add profile" = "Додати профіль"; @@ -397,12 +385,18 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Додайте сервери, відсканувавши QR-код."; +/* No comment provided by engineer. */ +"Add team members" = "Додайте учасників команди"; + /* No comment provided by engineer. */ "Add to another device" = "Додати до іншого пристрою"; /* No comment provided by engineer. */ "Add welcome message" = "Додати вітальне повідомлення"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Додайте членів своєї команди до розмов."; + /* No comment provided by engineer. */ "Added media & file servers" = "Додано медіа та файлові сервери"; @@ -760,7 +754,8 @@ /* rcv group event chat item */ "blocked %@" = "заблоковано %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "заблоковано адміністратором"; /* No comment provided by engineer. */ @@ -793,6 +788,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Болгарською, фінською, тайською та українською мовами - завдяки користувачам та [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Адреса підприємства"; + +/* No comment provided by engineer. */ +"Business chats" = "Ділові чати"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Через профіль чату (за замовчуванням) або [за з'єднанням](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -836,7 +837,7 @@ "Can't message member" = "Не можу надіслати повідомлення користувачеві"; /* alert action - alert button */ +alert button */ "Cancel" = "Скасувати"; /* No comment provided by engineer. */ @@ -891,7 +892,7 @@ "Change self-destruct mode" = "Змінити режим самознищення"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Змінити пароль самознищення"; /* chat item text */ @@ -909,6 +910,15 @@ /* chat item text */ "changing address…" = "змінює адресу…"; +/* No comment provided by engineer. */ +"Chat" = "Чат"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Чат вже існує"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Чат вже існує!"; + /* No comment provided by engineer. */ "Chat colors" = "Кольори чату"; @@ -954,6 +964,12 @@ /* No comment provided by engineer. */ "Chat theme" = "Тема чату"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Чат буде видалено для всіх учасників - цю дію неможливо скасувати!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Чат буде видалено для вас - цю дію неможливо скасувати!"; + /* No comment provided by engineer. */ "Chats" = "Чати"; @@ -1035,12 +1051,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Умови використання"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Умови будуть прийняті для ввімкнених операторів через 30 днів."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Умови приймаються для оператора(ів): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Для оператора(ів) приймаються умови: **%@**."; @@ -1440,7 +1450,8 @@ /* No comment provided by engineer. */ "decryption errors" = "помилки розшифровки"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "за замовчуванням (%@)"; /* No comment provided by engineer. */ @@ -1450,8 +1461,7 @@ "default (yes)" = "за замовчуванням (так)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "Видалити"; /* No comment provided by engineer. */ @@ -1475,12 +1485,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Видалити та повідомити контакт"; +/* No comment provided by engineer. */ +"Delete chat" = "Видалити чат"; + /* No comment provided by engineer. */ "Delete chat profile" = "Видалити профіль чату"; /* No comment provided by engineer. */ "Delete chat profile?" = "Видалити профіль чату?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Видалити чат?"; + /* No comment provided by engineer. */ "Delete connection" = "Видалити підключення"; @@ -1532,7 +1548,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Видалити повідомлення?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Видалити повідомлення"; /* No comment provided by engineer. */ @@ -1655,6 +1671,9 @@ /* chat feature */ "Direct messages" = "Прямі повідомлення"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "У цьому чаті заборонені прямі повідомлення між учасниками."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "У цій групі заборонені прямі повідомлення між учасниками."; @@ -1727,14 +1746,14 @@ /* No comment provided by engineer. */ "Don't enable" = "Не вмикати"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Більше не показувати"; /* No comment provided by engineer. */ "Downgrade and open chat" = "Пониження та відкритий чат"; /* alert button - chat item action */ +chat item action */ "Download" = "Завантажити"; /* No comment provided by engineer. */ @@ -1791,15 +1810,12 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Увімкнути (зберегти перевизначення)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Увімкнути автоматичне видалення повідомлень?"; /* No comment provided by engineer. */ "Enable camera access" = "Увімкніть доступ до камери"; -/* No comment provided by engineer. */ -"Enable Flux" = "Увімкнути Flux"; - /* No comment provided by engineer. */ "Enable for all" = "Увімкнути для всіх"; @@ -2073,7 +2089,7 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Помилка перепідключення серверів"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Помилка видалення учасника"; /* No comment provided by engineer. */ @@ -2154,7 +2170,9 @@ /* No comment provided by engineer. */ "Error: " = "Помилка: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Помилка: %@"; /* No comment provided by engineer. */ @@ -2172,9 +2190,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Навіть коли вимкнений у розмові."; -/* No comment provided by engineer. */ -"event happened" = "відбулася подія"; - /* No comment provided by engineer. */ "Exit without saving" = "Вихід без збереження"; @@ -2214,7 +2229,7 @@ /* swipe action */ "Favorite" = "Улюблений"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Помилка файлу"; /* alert message */ @@ -2295,9 +2310,6 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Виправлення не підтримується учасником групи"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "для кращої конфіденційності метаданих."; - /* servers error */ "For chat profile %@:" = "Для профілю чату %@:"; @@ -2694,6 +2706,9 @@ /* No comment provided by engineer. */ "Invite members" = "Запросити учасників"; +/* No comment provided by engineer. */ +"Invite to chat" = "Запросити в чат"; + /* No comment provided by engineer. */ "Invite to group" = "Запросити до групи"; @@ -2808,6 +2823,12 @@ /* swipe action */ "Leave" = "Залишити"; +/* No comment provided by engineer. */ +"Leave chat" = "Вийти з чату"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Залишити чат?"; + /* No comment provided by engineer. */ "Leave group" = "Покинути групу"; @@ -2904,12 +2925,18 @@ /* item status text */ "Member inactive" = "Користувач неактивний"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Роль учасника буде змінено на \"%@\". Усі учасники чату отримають сповіщення."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Роль учасника буде змінено на \"%@\". Всі учасники групи будуть повідомлені про це."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль учасника буде змінено на \"%@\". Учасник отримає нове запрошення."; +/* No comment provided by engineer. */ +"Member will be removed from chat - this cannot be undone!" = "Учасника буде видалено з чату – це неможливо скасувати!"; + /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Учасник буде видалений з групи - це неможливо скасувати!"; @@ -3096,10 +3123,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Кілька профілів чату"; -/* No comment provided by engineer. */ -"mute" = "приглушити"; - -/* swipe action */ +/* notification label action */ "Mute" = "Вимкнути звук"; /* No comment provided by engineer. */ @@ -3132,7 +3156,7 @@ /* No comment provided by engineer. */ "Network status" = "Стан мережі"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ніколи"; /* No comment provided by engineer. */ @@ -3292,8 +3316,9 @@ "observer" = "спостерігач"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "вимкнено"; /* blur media */ @@ -3305,7 +3330,8 @@ /* feature offered item */ "offered %@: %@" = "запропонував %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Гаразд"; /* No comment provided by engineer. */ @@ -3329,6 +3355,9 @@ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Onion хости не будуть використовуватися."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Лише власники чату можуть змінювати налаштування."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Тільки клієнтські пристрої зберігають профілі користувачів, контакти, групи та повідомлення, надіслані за допомогою **2-шарового наскрізного шифрування**."; @@ -3374,7 +3403,7 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Тільки ваш контакт може надсилати голосові повідомлення."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Відкрито"; /* No comment provided by engineer. */ @@ -3407,6 +3436,9 @@ /* alert title */ "Operator server" = "Сервер оператора"; +/* No comment provided by engineer. */ +"Or import archive file" = "Або імпортуйте архівний файл"; + /* No comment provided by engineer. */ "Or paste archive link" = "Або вставте посилання на архів"; @@ -3575,6 +3607,9 @@ /* No comment provided by engineer. */ "Privacy & security" = "Конфіденційність і безпека"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Конфіденційність для ваших клієнтів."; + /* No comment provided by engineer. */ "Privacy redefined" = "Конфіденційність переглянута"; @@ -3789,7 +3824,7 @@ "Reduced battery usage" = "Зменшення використання акумулятора"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Відхилити"; /* No comment provided by engineer. */ @@ -3867,6 +3902,9 @@ /* chat item action */ "Reply" = "Відповісти"; +/* chat list item title */ +"requested to connect" = "запит на підключення"; + /* No comment provided by engineer. */ "Required" = "Потрібно"; @@ -3921,9 +3959,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Умови перегляду"; -/* No comment provided by engineer. */ -"Review later" = "Перегляньте пізніше"; - /* No comment provided by engineer. */ "Revoke" = "Відкликати"; @@ -3946,7 +3981,7 @@ "Safer groups" = "Безпечніші групи"; /* alert button - chat item action */ +chat item action */ "Save" = "Зберегти"; /* alert button */ @@ -4310,7 +4345,7 @@ "Shape profile images" = "Сформуйте зображення профілю"; /* alert action - chat item action */ +chat item action */ "Share" = "Поділіться"; /* No comment provided by engineer. */ @@ -4391,6 +4426,9 @@ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "SimpleX адреса або одноразове посилання?"; +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat і Flux уклали угоду про включення серверів, керованих Flux, у додаток."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "Безпека SimpleX Chat була перевірена компанією Trail of Bits."; @@ -4571,6 +4609,9 @@ /* No comment provided by engineer. */ "Tap button " = "Натисніть кнопку "; +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше."; + /* No comment provided by engineer. */ "Tap to activate profile." = "Натисніть, щоб активувати профіль."; @@ -4604,7 +4645,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Тимчасова помилка файлу"; /* server test failure */ @@ -4682,15 +4723,9 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Стара база даних не була видалена під час міграції, її можна видалити."; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Профіль доступний лише вашим контактам."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Такі ж умови діятимуть і для оператора **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Такі ж умови будуть застосовуватися до оператора(ів): **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Другий попередньо встановлений оператор у застосунку!"; @@ -4937,10 +4972,7 @@ /* authentication reason */ "Unlock app" = "Розблокувати додаток"; -/* No comment provided by engineer. */ -"unmute" = "увімкнути звук"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Увімкнути звук"; /* No comment provided by engineer. */ @@ -5279,6 +5311,9 @@ /* No comment provided by engineer. */ "You are already connected to %@." = "Ви вже підключені до %@."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Ви вже підключені до %@."; + /* No comment provided by engineer. */ "You are already connecting to %@." = "Ви вже з'єднані з %@."; @@ -5324,9 +5359,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Ви можете змінити його в налаштуваннях зовнішнього вигляду."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Ви можете налаштувати операторів у налаштуваннях Мережі та серверів."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Ви можете налаштувати сервери за допомогою налаштувань."; @@ -5477,6 +5509,9 @@ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Ви більше не будете отримувати повідомлення з цього чату. Історія чату буде збережена."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Ви перестанете отримувати повідомлення від цієї групи. Історія чату буде збережена."; @@ -5543,15 +5578,15 @@ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Ваш профіль **%@** буде опублікований."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Профіль доступний лише вашим контактам."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профіль зберігається на вашому пристрої і доступний лише вашим контактам. Сервери SimpleX не бачать ваш профіль."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ваш профіль було змінено. Якщо ви збережете його, оновлений профіль буде надіслано всім вашим контактам."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої."; - /* No comment provided by engineer. */ "Your random profile" = "Ваш випадковий профіль"; @@ -5567,6 +5602,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Ваша адреса SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Ваші SMP-сервери"; - diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 62ff2088c2..19d7c268d4 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (可复制)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 语音消息最长5分钟。\n- 自定义限时消息。\n- 编辑消息历史。"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 种彩色!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(新)"; /* No comment provided by engineer. */ "(this device v%@)" = "(此设备 v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[贡献](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -82,6 +61,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**推荐**:设备令牌和通知会发送至 SimpleX Chat 通知服务器,但是消息内容、大小或者发送人不会。"; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**扫描/粘贴链接**:用您收到的链接连接。"; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**警告**:及时推送通知需要保存在钥匙串的密码。"; @@ -142,12 +124,21 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ 已认证"; +/* No comment provided by engineer. */ +"%@ server" = "服务器"; + +/* No comment provided by engineer. */ +"%@ servers" = "服务器"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ 已上传"; /* notification title */ "%@ wants to connect!" = "%@ 要连接!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@, %@ 和 %lld 成员"; @@ -160,9 +151,24 @@ /* time interval */ "%d days" = "%d 天"; +/* forward confirmation reason */ +"%d file(s) are still being downloaded." = "仍在下载 %d 个文件。"; + +/* forward confirmation reason */ +"%d file(s) failed to download." = "%d 个文件下载失败。"; + +/* forward confirmation reason */ +"%d file(s) were deleted." = "已刪除 %d 个文件。"; + +/* forward confirmation reason */ +"%d file(s) were not downloaded." = "未能下载 %d 个文件。"; + /* time interval */ "%d hours" = "%d 小时"; +/* alert title */ +"%d messages not forwarded" = "未转发 %d 条消息"; + /* time interval */ "%d min" = "%d 分钟"; @@ -172,8 +178,11 @@ /* time interval */ "%d sec" = "%d 秒"; +/* delete after time */ +"%d seconds(s)" = "%d 秒"; + /* integrity error chat item */ -"%d skipped message(s)" = "%d 跳过消息"; +"%d skipped message(s)" = "跳过的 %d 条消息"; /* time interval */ "%d weeks" = "%d 星期"; @@ -214,9 +223,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld 种新的界面语言"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld 秒"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld 秒"; @@ -262,7 +268,8 @@ /* No comment provided by engineer. */ "0s" = "0秒"; -/* time interval */ +/* delete after time +time interval */ "1 day" = "1天"; /* time interval */ @@ -271,12 +278,23 @@ /* No comment provided by engineer. */ "1 minute" = "1分钟"; -/* time interval */ +/* delete after time +time interval */ "1 month" = "1月"; -/* time interval */ +/* delete after time +time interval */ "1 week" = "1周"; +/* delete after time */ +"1 year" = "1 年"; + +/* No comment provided by engineer. */ +"1-time link" = "一次性链接"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "一次性链接*只能给一名联系人*使用。当面或使用聊天应用分享链接。"; + /* No comment provided by engineer. */ "5 minutes" = "5分钟"; @@ -310,6 +328,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "中止地址更改?"; +/* No comment provided by engineer. */ +"About operators" = "关于运营方"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "关于SimpleX Chat"; @@ -320,10 +341,13 @@ "Accent" = "强调"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "接受"; +/* No comment provided by engineer. */ +"Accept conditions" = "接受条款"; + /* No comment provided by engineer. */ "Accept connection request?" = "接受联系人?"; @@ -331,24 +355,36 @@ "Accept contact request from %@?" = "接受来自 %@ 的联系人请求?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "接受隐身聊天"; /* call status */ "accepted call" = "已接受通话"; +/* No comment provided by engineer. */ +"Accepted conditions" = "已接受的条款"; + /* No comment provided by engineer. */ "Acknowledged" = "确认"; /* No comment provided by engineer. */ "Acknowledgement errors" = "确认错误"; +/* token status text */ +"Active" = "活跃"; + /* No comment provided by engineer. */ "Active connections" = "活动连接"; /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "将地址添加到您的个人资料,以便您的联系人可以与其他人共享。个人资料更新将发送给您的联系人。"; +/* No comment provided by engineer. */ +"Add friends" = "添加好友"; + +/* No comment provided by engineer. */ +"Add list" = "添加列表"; + /* No comment provided by engineer. */ "Add profile" = "添加个人资料"; @@ -358,12 +394,27 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "扫描二维码来添加服务器。"; +/* No comment provided by engineer. */ +"Add team members" = "添加团队成员"; + /* No comment provided by engineer. */ "Add to another device" = "添加另一设备"; +/* No comment provided by engineer. */ +"Add to list" = "添加到列表"; + /* No comment provided by engineer. */ "Add welcome message" = "添加欢迎信息"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "将你的团队成员加入对话。"; + +/* No comment provided by engineer. */ +"Added media & file servers" = "已添加媒体和文件服务器"; + +/* No comment provided by engineer. */ +"Added message servers" = "已添加消息服务器"; + /* No comment provided by engineer. */ "Additional accent" = "附加重音"; @@ -379,6 +430,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "将中止地址更改。将使用旧接收地址。"; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "地址还是一次性链接?"; + +/* No comment provided by engineer. */ +"Address settings" = "地址设置"; + /* member role */ "admin" = "管理员"; @@ -403,12 +460,18 @@ /* chat item text */ "agreeing encryption…" = "同意加密…"; +/* No comment provided by engineer. */ +"All" = "全部"; + /* No comment provided by engineer. */ "All app data is deleted." = "已删除所有应用程序数据。"; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "所有聊天记录和消息将被删除——这一行为无法撤销!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "列表 %@ 和其中全部聊天将被删除。"; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "所有数据在输入后将被删除。"; @@ -421,6 +484,9 @@ /* feature role */ "all members" = "所有成员"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。"; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "所有消息都将被删除 - 这无法被撤销!"; @@ -433,6 +499,12 @@ /* profile dropdown */ "All profiles" = "所有配置文件"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "将为你存档所有举报。"; + +/* No comment provided by engineer. */ +"All servers" = "全部服务器"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "所有联系人会保持连接。"; @@ -478,6 +550,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "允许不可撤回地删除已发送消息"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "允许向 moderators 举报消息。"; + /* No comment provided by engineer. */ "Allow to send files and media." = "允许发送文件和媒体。"; @@ -532,6 +607,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "和 %lld 其他事件"; +/* report reason */ +"Another reason" = "另一个理由"; + /* No comment provided by engineer. */ "Answer call" = "接听来电"; @@ -547,6 +625,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "应用程序为新的本地文件(视频除外)加密。"; +/* No comment provided by engineer. */ +"App group:" = "应用组:"; + /* No comment provided by engineer. */ "App icon" = "应用程序图标"; @@ -556,6 +637,9 @@ /* No comment provided by engineer. */ "App passcode is replaced with self-destruct passcode." = "应用程序密码被替换为自毁密码。"; +/* No comment provided by engineer. */ +"App session" = "应用会话"; + /* No comment provided by engineer. */ "App version" = "应用程序版本"; @@ -571,12 +655,30 @@ /* No comment provided by engineer. */ "Apply to" = "应用于"; +/* No comment provided by engineer. */ +"Archive" = "存档"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "存档 %lld 个举报?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "存档所有举报?"; + /* No comment provided by engineer. */ "Archive and upload" = "存档和上传"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "存档联系人以便稍后聊天."; +/* No comment provided by engineer. */ +"Archive report" = "存档举报"; + +/* No comment provided by engineer. */ +"Archive report?" = "存档举报?"; + +/* swipe action */ +"Archive reports" = "存档举报"; + /* No comment provided by engineer. */ "Archived contacts" = "已存档的联系人"; @@ -628,6 +730,9 @@ /* No comment provided by engineer. */ "Auto-accept images" = "自动接受图片"; +/* alert title */ +"Auto-accept settings" = "自动接受设置"; + /* No comment provided by engineer. */ "Back" = "返回"; @@ -649,15 +754,36 @@ /* No comment provided by engineer. */ "Bad message ID" = "错误消息 ID"; +/* No comment provided by engineer. */ +"Better calls" = "更佳的通话"; + /* No comment provided by engineer. */ "Better groups" = "更佳的群组"; +/* No comment provided by engineer. */ +"Better groups performance" = "更好的群性能"; + +/* No comment provided by engineer. */ +"Better message dates." = "更好的消息日期。"; + /* No comment provided by engineer. */ "Better messages" = "更好的消息"; /* No comment provided by engineer. */ "Better networking" = "更好的网络"; +/* No comment provided by engineer. */ +"Better notifications" = "更佳的通知"; + +/* No comment provided by engineer. */ +"Better privacy and security" = "更好的隐私和安全"; + +/* No comment provided by engineer. */ +"Better security ✅" = "更佳的安全性✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "更佳的使用体验"; + /* No comment provided by engineer. */ "Black" = "黑色"; @@ -685,7 +811,8 @@ /* rcv group event chat item */ "blocked %@" = "已封禁 %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "由管理员封禁"; /* No comment provided by engineer. */ @@ -718,9 +845,21 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "企业地址"; + +/* No comment provided by engineer. */ +"Business chats" = "企业聊天"; + +/* No comment provided by engineer. */ +"Businesses" = "企业"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。"; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "使用 SimpleX Chat 代表您同意:\n- 在公开群中只发送合法内容\n- 尊重其他用户 – 没有垃圾信息。"; + /* No comment provided by engineer. */ "call" = "呼叫"; @@ -761,7 +900,7 @@ "Can't message member" = "无法向成员发送消息"; /* alert action - alert button */ +alert button */ "Cancel" = "取消"; /* No comment provided by engineer. */ @@ -788,6 +927,12 @@ /* No comment provided by engineer. */ "Change" = "更改"; +/* alert title */ +"Change automatic message deletion?" = "更改消息自动删除设置?"; + +/* authentication reason */ +"Change chat profiles" = "更改聊天资料"; + /* No comment provided by engineer. */ "Change database passphrase?" = "更改数据库密码?"; @@ -813,7 +958,7 @@ "Change self-destruct mode" = "更改自毁模式"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "更改自毁密码"; /* chat item text */ @@ -831,6 +976,15 @@ /* chat item text */ "changing address…" = "更改地址…"; +/* No comment provided by engineer. */ +"Chat" = "聊天"; + +/* No comment provided by engineer. */ +"Chat already exists" = "聊天已存在"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "聊天已存在!"; + /* No comment provided by engineer. */ "Chat colors" = "聊天颜色"; @@ -867,15 +1021,30 @@ /* No comment provided by engineer. */ "Chat preferences" = "聊天偏好设置"; +/* alert message */ +"Chat preferences were changed." = "聊天偏好设置已修改。"; + /* No comment provided by engineer. */ "Chat profile" = "用户资料"; /* No comment provided by engineer. */ "Chat theme" = "聊天主题"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "将为所有成员删除聊天 - 此操作无法撤销!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "将为你删除聊天 - 此操作无法撤销!"; + /* No comment provided by engineer. */ "Chats" = "聊天"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "每 20 分钟检查消息。"; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "在被允许时检查消息。"; + /* alert title */ "Check server address and try again." = "检查服务器地址并再试一次。"; @@ -909,6 +1078,12 @@ /* No comment provided by engineer. */ "Clear conversation?" = "清除对话吗?"; +/* No comment provided by engineer. */ +"Clear group?" = "清除群?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "清除还是删除群?"; + /* No comment provided by engineer. */ "Clear private notes?" = "清除私密笔记?"; @@ -924,6 +1099,9 @@ /* No comment provided by engineer. */ "colored" = "彩色"; +/* report reason */ +"Community guidelines violation" = "违反社区指导方针"; + /* server test step */ "Compare file" = "对比文件"; @@ -936,9 +1114,33 @@ /* No comment provided by engineer. */ "Completed" = "已完成"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "已于 %@ 接受条款。"; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "已接受运营方 **%@** 的条款。"; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "已经接受下列运营方的条款:**%@**。"; + +/* No comment provided by engineer. */ +"Conditions of use" = "使用条款"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "将接受下列运营方的条款:**%@**。"; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "将于 %@ 接受条款。"; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "将在 %@ 自动接受启用的运营方的条款。"; + /* No comment provided by engineer. */ "Configure ICE servers" = "配置 ICE 服务器"; +/* No comment provided by engineer. */ +"Configure server operators" = "配置服务器运营方"; + /* No comment provided by engineer. */ "Confirm" = "确认"; @@ -969,6 +1171,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "确认上传"; +/* token status text */ +"Confirmed" = "已确定"; + /* server test step */ "Connect" = "连接"; @@ -1068,6 +1273,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "连接和服务器状态。"; +/* No comment provided by engineer. */ +"Connection blocked" = "连接被阻止"; + /* No comment provided by engineer. */ "Connection error" = "连接错误"; @@ -1077,12 +1285,24 @@ /* chat list item title (it should not be shown */ "connection established" = "连接已建立"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "连接被运营方 %@ 阻止"; + +/* No comment provided by engineer. */ +"Connection not ready." = "连接未就绪。"; + /* No comment provided by engineer. */ "Connection notifications" = "连接通知"; /* No comment provided by engineer. */ "Connection request sent!" = "已发送连接请求!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "连接需要加密重协商。"; + +/* No comment provided by engineer. */ +"Connection security" = "连接安全性"; + /* No comment provided by engineer. */ "Connection terminated" = "连接被终止"; @@ -1140,6 +1360,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "联系人可以将信息标记为删除;您将可以查看这些信息。"; +/* blocking reason */ +"Content violates conditions of use" = "内容违反使用条款"; + /* No comment provided by engineer. */ "Continue" = "继续"; @@ -1155,12 +1378,18 @@ /* No comment provided by engineer. */ "Core version: v%@" = "核心版本: v%@"; +/* No comment provided by engineer. */ +"Corner" = "拐角"; + /* No comment provided by engineer. */ "Correct name to %@?" = "将名称更正为 %@?"; /* No comment provided by engineer. */ "Create" = "创建"; +/* No comment provided by engineer. */ +"Create 1-time link" = "创建一次性链接"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "使用随机身份创建群组."; @@ -1176,6 +1405,9 @@ /* No comment provided by engineer. */ "Create link" = "创建链接"; +/* No comment provided by engineer. */ +"Create list" = "创建列表"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻"; @@ -1212,6 +1444,9 @@ /* No comment provided by engineer. */ "creator" = "创建者"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "无法加载当前条款文本,你可以通过此链接审阅条款:"; + /* No comment provided by engineer. */ "Current Passcode" = "当前密码"; @@ -1230,6 +1465,9 @@ /* No comment provided by engineer. */ "Custom time" = "自定义时间"; +/* No comment provided by engineer. */ +"Customizable message shape." = "可自定义消息形状。"; + /* No comment provided by engineer. */ "Customize theme" = "自定义主题"; @@ -1311,7 +1549,8 @@ /* No comment provided by engineer. */ "decryption errors" = "解密错误"; -/* pref value */ +/* delete after time +pref value */ "default (%@)" = "默认 (%@)"; /* No comment provided by engineer. */ @@ -1321,8 +1560,7 @@ "default (yes)" = "默认 (是)"; /* alert action - chat item action - swipe action */ +swipe action */ "Delete" = "删除"; /* No comment provided by engineer. */ @@ -1346,12 +1584,21 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "删除并通知联系人"; +/* No comment provided by engineer. */ +"Delete chat" = "删除聊天"; + +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "从你的设备删除聊天消息。"; + /* No comment provided by engineer. */ "Delete chat profile" = "删除聊天资料"; /* No comment provided by engineer. */ "Delete chat profile?" = "删除聊天资料?"; +/* No comment provided by engineer. */ +"Delete chat?" = "删除聊天?"; + /* No comment provided by engineer. */ "Delete connection" = "删除连接"; @@ -1397,13 +1644,16 @@ /* No comment provided by engineer. */ "Delete link?" = "删除链接?"; +/* alert title */ +"Delete list?" = "删除列表?"; + /* No comment provided by engineer. */ "Delete member message?" = "删除成员消息?"; /* No comment provided by engineer. */ "Delete message?" = "删除消息吗?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "删除消息"; /* No comment provided by engineer. */ @@ -1415,6 +1665,9 @@ /* No comment provided by engineer. */ "Delete old database?" = "删除旧数据库吗?"; +/* No comment provided by engineer. */ +"Delete or moderate up to 200 messages." = "允许自行删除或管理员移除最多200条消息。"; + /* No comment provided by engineer. */ "Delete pending connection?" = "删除待定连接?"; @@ -1424,6 +1677,9 @@ /* server test step */ "Delete queue" = "删除队列"; +/* No comment provided by engineer. */ +"Delete report" = "删除举报"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "一次最多删除 20 条信息。"; @@ -1454,6 +1710,9 @@ /* No comment provided by engineer. */ "Deletion errors" = "删除错误"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "已送达,即使苹果已将其删除。"; + /* No comment provided by engineer. */ "Delivery" = "传送"; @@ -1503,7 +1762,7 @@ "Device" = "设备"; /* No comment provided by engineer. */ -"Device authentication is disabled. Turning off SimpleX Lock." = "设备验证被禁用。关闭 SimpleX 锁定。"; +"Device authentication is disabled. Turning off SimpleX Lock." = "设备验证已禁用。 SimpleX 已解锁。"; /* No comment provided by engineer. */ "Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "没有启用设备验证。一旦启用设备验证,您可以通过设置打开 SimpleX 锁定。"; @@ -1521,11 +1780,20 @@ "Direct messages" = "私信"; /* No comment provided by engineer. */ -"Direct messages between members are prohibited." = "此群中禁止成员之间私信。"; +"Direct messages between members are prohibited in this chat." = "此群禁止成员间私信。"; + +/* No comment provided by engineer. */ +"Direct messages between members are prohibited." = "此群禁止成员间私信。"; /* No comment provided by engineer. */ "Disable (keep overrides)" = "禁用(保留覆盖)"; +/* alert title */ +"Disable automatic message deletion?" = "禁用消息自动销毁?"; + +/* alert button */ +"Disable delete messages" = "停用消息删除"; + /* No comment provided by engineer. */ "Disable for all" = "全部禁用"; @@ -1577,12 +1845,18 @@ /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "请勿直接发送消息,即使您的服务器或目标服务器不支持私有路由。"; +/* No comment provided by engineer. */ +"Do not use credentials with proxy." = "代理不使用身份验证凭据。"; + /* No comment provided by engineer. */ "Do NOT use private routing." = "不要使用私有路由。"; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "请勿使用 SimpleX 进行紧急通话。"; +/* No comment provided by engineer. */ +"Documents:" = "文档:"; + /* No comment provided by engineer. */ "Don't create address" = "不创建地址"; @@ -1590,13 +1864,19 @@ "Don't enable" = "不要启用"; /* No comment provided by engineer. */ +"Don't miss important messages." = "不错过重要消息。"; + +/* alert action */ "Don't show again" = "不再显示"; +/* No comment provided by engineer. */ +"Done" = "完成"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "降级并打开聊天"; /* alert button - chat item action */ +chat item action */ "Download" = "下载"; /* No comment provided by engineer. */ @@ -1608,6 +1888,9 @@ /* server test step */ "Download file" = "下载文件"; +/* alert action */ +"Download files" = "下载文件"; + /* No comment provided by engineer. */ "Downloaded" = "已下载"; @@ -1627,7 +1910,7 @@ "duplicate message" = "重复的消息"; /* No comment provided by engineer. */ -"duplicates" = "复本"; +"duplicates" = "副本"; /* No comment provided by engineer. */ "Duration" = "时长"; @@ -1635,6 +1918,9 @@ /* No comment provided by engineer. */ "e2e encrypted" = "端到端加密"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "端到端加密的通知。"; + /* chat item action */ "Edit" = "编辑"; @@ -1647,12 +1933,15 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "启用(保持覆盖)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "启用自动删除消息?"; /* No comment provided by engineer. */ "Enable camera access" = "启用相机访问"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。"; + /* No comment provided by engineer. */ "Enable for all" = "全部启用"; @@ -1764,6 +2053,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "需要为 %@ 重新进行加密协商"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "正进行加密重协商。"; + /* No comment provided by engineer. */ "ended" = "已结束"; @@ -1812,21 +2104,33 @@ /* No comment provided by engineer. */ "Error aborting address change" = "中止地址更改错误"; +/* alert title */ +"Error accepting conditions" = "接受条款出错"; + /* No comment provided by engineer. */ "Error accepting contact request" = "接受联系人请求错误"; /* No comment provided by engineer. */ "Error adding member(s)" = "添加成员错误"; +/* alert title */ +"Error adding server" = "添加服务器出错"; + /* No comment provided by engineer. */ "Error changing address" = "更改地址错误"; +/* No comment provided by engineer. */ +"Error changing connection profile" = "更改连接资料出错"; + /* No comment provided by engineer. */ "Error changing role" = "更改角色错误"; /* No comment provided by engineer. */ "Error changing setting" = "更改设置错误"; +/* No comment provided by engineer. */ +"Error changing to incognito!" = "切换至隐身聊天出错!"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "连接到转发服务器 %@ 时出错。请稍后尝试。"; @@ -1839,6 +2143,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "创建群组链接错误"; +/* alert title */ +"Error creating list" = "创建列表出错"; + /* No comment provided by engineer. */ "Error creating member contact" = "创建成员联系人时出错"; @@ -1848,6 +2155,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "创建资料错误!"; +/* No comment provided by engineer. */ +"Error creating report" = "创建举报出错"; + /* No comment provided by engineer. */ "Error decrypting file" = "解密文件时出错"; @@ -1896,6 +2206,12 @@ /* No comment provided by engineer. */ "Error joining group" = "加入群组错误"; +/* alert title */ +"Error loading servers" = "加载服务器出错"; + +/* No comment provided by engineer. */ +"Error migrating settings" = "迁移设置出错"; + /* No comment provided by engineer. */ "Error opening chat" = "打开聊天时出错"; @@ -1908,12 +2224,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "重新连接服务器时出错"; -/* No comment provided by engineer. */ +/* alert title */ +"Error registering for notifications" = "注册消息推送出错"; + +/* alert title */ "Error removing member" = "删除成员错误"; +/* alert title */ +"Error reordering lists" = "重排列表出错"; + /* No comment provided by engineer. */ "Error resetting statistics" = "重置统计信息时出错"; +/* alert title */ +"Error saving chat list" = "保存聊天列表出错"; + /* No comment provided by engineer. */ "Error saving group profile" = "保存群组资料错误"; @@ -1926,6 +2251,9 @@ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "保存密码到钥匙串错误"; +/* alert title */ +"Error saving servers" = "保存服务器出错"; + /* when migrating */ "Error saving settings" = "保存设置出错"; @@ -1953,18 +2281,27 @@ /* No comment provided by engineer. */ "Error stopping chat" = "停止聊天错误"; +/* No comment provided by engineer. */ +"Error switching profile" = "切换配置文件出错"; + /* alertTitle */ "Error switching profile!" = "切换资料错误!"; /* No comment provided by engineer. */ "Error synchronizing connection" = "同步连接错误"; +/* No comment provided by engineer. */ +"Error testing server connection" = "检验服务器连接出错"; + /* No comment provided by engineer. */ "Error updating group link" = "更新群组链接错误"; /* No comment provided by engineer. */ "Error updating message" = "更新消息错误"; +/* alert title */ +"Error updating server" = "更新服务器出错"; + /* No comment provided by engineer. */ "Error updating settings" = "更新设置错误"; @@ -1980,7 +2317,9 @@ /* No comment provided by engineer. */ "Error: " = "错误: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "错误: %@"; /* No comment provided by engineer. */ @@ -1992,11 +2331,11 @@ /* No comment provided by engineer. */ "Errors" = "错误"; -/* No comment provided by engineer. */ -"Even when disabled in the conversation." = "即使在对话中被禁用。"; +/* servers error */ +"Errors in servers configuration." = "服务器配置有错误。"; /* No comment provided by engineer. */ -"event happened" = "发生的事"; +"Even when disabled in the conversation." = "即使在对话中被禁用。"; /* No comment provided by engineer. */ "Exit without saving" = "退出而不保存"; @@ -2007,6 +2346,9 @@ /* No comment provided by engineer. */ "expired" = "过期"; +/* token status text */ +"Expired" = "已过期"; + /* No comment provided by engineer. */ "Export database" = "导出数据库"; @@ -2031,15 +2373,30 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "快速且无需等待发件人在线!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "更快地删除群。"; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "加入速度更快、信息更可靠。"; +/* No comment provided by engineer. */ +"Faster sending messages." = "更快发送消息。"; + /* swipe action */ "Favorite" = "最喜欢"; /* No comment provided by engineer. */ +"Favorites" = "收藏"; + +/* file error alert title */ "File error" = "文件错误"; +/* alert message */ +"File errors:\n%@" = "文件错误:\n%@"; + +/* file error text */ +"File is blocked by server operator:\n%@." = "文件被服务器运营方阻止:\n%@。"; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "找不到文件 - 很可能文件已被删除或取消。"; @@ -2115,15 +2472,45 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "修复群组成员不支持的问题"; +/* No comment provided by engineer. */ +"For all moderators" = "所有 moderators"; + +/* servers error */ +"For chat profile %@:" = "为聊天资料 %@:"; + /* No comment provided by engineer. */ "For console" = "用于控制台"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "比如,如果你通过 SimpleX 服务器收到消息,应用会通过 Flux 服务器传送它们。"; + +/* No comment provided by engineer. */ +"For me" = "仅自己"; + +/* No comment provided by engineer. */ +"For private routing" = "用于私密路由"; + +/* No comment provided by engineer. */ +"For social media" = "用于社交媒体"; + /* chat item action */ "Forward" = "转发"; +/* alert title */ +"Forward %d message(s)?" = "转发 %d 条消息?"; + /* No comment provided by engineer. */ "Forward and save messages" = "转发并保存消息"; +/* alert action */ +"Forward messages" = "已转发的消息"; + +/* alert message */ +"Forward messages without files?" = "仅转发消息不转发文件?"; + +/* No comment provided by engineer. */ +"Forward up to 20 messages at once." = "一次转发最多20条消息。"; + /* No comment provided by engineer. */ "forwarded" = "已转发"; @@ -2133,6 +2520,9 @@ /* No comment provided by engineer. */ "Forwarded from" = "转发自"; +/* No comment provided by engineer. */ +"Forwarding %lld messages" = "正在转发 %lld 条消息"; + /* No comment provided by engineer. */ "Forwarding server %@ failed to connect to destination server %@. Please try later." = "转发服务器 %@ 无法连接到目标服务器 %@。请稍后尝试。"; @@ -2169,6 +2559,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "进一步减少电池使用"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "被提及时收到通知。"; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF 和贴纸"; @@ -2241,9 +2634,15 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "将为您删除群组——此操作无法撤消!"; +/* No comment provided by engineer. */ +"Groups" = "群"; + /* No comment provided by engineer. */ "Help" = "帮助"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "帮助管理员管理群组。"; + /* No comment provided by engineer. */ "Hidden" = "隐藏"; @@ -2274,6 +2673,15 @@ /* time unit */ "hours" = "小时"; +/* No comment provided by engineer. */ +"How it affects privacy" = "它如何影响隐私"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "它如何帮助隐私"; + +/* alert button */ +"How it works" = "工作原理"; + /* No comment provided by engineer. */ "How SimpleX works" = "SimpleX的工作原理"; @@ -2337,6 +2745,9 @@ /* No comment provided by engineer. */ "Importing archive" = "正在导入存档"; +/* No comment provided by engineer. */ +"Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" = "改善传送,降低流量使用。\n更多改进即将推出!"; + /* No comment provided by engineer. */ "Improved message delivery" = "改进了消息传递"; @@ -2358,6 +2769,12 @@ /* No comment provided by engineer. */ "inactive" = "无效"; +/* report reason */ +"Inappropriate content" = "不当内容"; + +/* report reason */ +"Inappropriate profile" = "不当个人资料"; + /* No comment provided by engineer. */ "Incognito" = "隐身聊天"; @@ -2424,6 +2841,21 @@ /* No comment provided by engineer. */ "Interface colors" = "界面颜色"; +/* token status text */ +"Invalid" = "无效"; + +/* token status text */ +"Invalid (bad token)" = "Token 无效"; + +/* token status text */ +"Invalid (expired)" = "无效(已过期)"; + +/* token status text */ +"Invalid (unregistered)" = "无效(未注册)"; + +/* token status text */ +"Invalid (wrong topic)" = "无效(话题有误)"; + /* invalid chat data */ "invalid chat" = "无效聊天"; @@ -2475,6 +2907,9 @@ /* No comment provided by engineer. */ "Invite members" = "邀请成员"; +/* No comment provided by engineer. */ +"Invite to chat" = "邀请加入聊天"; + /* No comment provided by engineer. */ "Invite to group" = "邀请加入群组"; @@ -2496,6 +2931,9 @@ /* No comment provided by engineer. */ "iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "在您重启应用或改变密码后,iOS钥匙串将被用来安全地存储密码——它将允许接收推送通知。"; +/* No comment provided by engineer. */ +"IP address" = "IP 地址"; + /* No comment provided by engineer. */ "Irreversible message deletion" = "不可撤回消息移除"; @@ -2586,6 +3024,12 @@ /* swipe action */ "Leave" = "离开"; +/* No comment provided by engineer. */ +"Leave chat" = "离开聊天"; + +/* No comment provided by engineer. */ +"Leave chat?" = "离开聊天?"; + /* No comment provided by engineer. */ "Leave group" = "离开群组"; @@ -2613,6 +3057,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "已链接桌面"; +/* swipe action */ +"List" = "列表"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "所有列表的名称和表情符号都应不同。"; + +/* No comment provided by engineer. */ +"List name..." = "列表名…"; + /* No comment provided by engineer. */ "LIVE" = "实时"; @@ -2682,12 +3135,21 @@ /* item status text */ "Member inactive" = "成员不活跃"; +/* chat feature */ +"Member reports" = "成员举报"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "将变更成员角色为“%@”。所有成员都会收到通知。"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "成员角色将更改为 \"%@\"。所有群成员将收到通知。"; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "成员角色将更改为 \"%@\"。该成员将收到一份新的邀请。"; +/* No comment provided by engineer. */ +"Member will be removed from chat - this cannot be undone!" = "将从聊天中删除成员 - 此操作无法撤销!"; + /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "成员将被移出群组——此操作无法撤消!"; @@ -2697,6 +3159,9 @@ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "群组成员可以不可撤回地删除已发送的消息"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "成员可以向 moderators 举报消息。"; + /* No comment provided by engineer. */ "Members can send direct messages." = "群组成员可以私信。"; @@ -2712,6 +3177,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "群组成员可以发送语音消息。"; +/* No comment provided by engineer. */ +"Mention members 👋" = "提及成员👋"; + /* No comment provided by engineer. */ "Menus" = "菜单"; @@ -2757,6 +3225,9 @@ /* No comment provided by engineer. */ "Message servers" = "消息服务器"; +/* No comment provided by engineer. */ +"Message shape" = "消息形状"; + /* No comment provided by engineer. */ "Message source remains private." = "消息来源保持私密。"; @@ -2781,12 +3252,18 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "将显示来自 %@ 的消息!"; +/* alert message */ +"Messages in this chat will never be deleted." = "此聊天中的消息永远不会被删除。"; + /* No comment provided by engineer. */ "Messages received" = "收到的消息"; /* No comment provided by engineer. */ "Messages sent" = "已发送的消息"; +/* alert message */ +"Messages were deleted after you selected them." = "在你选中消息后这些消息已被删除。"; + /* No comment provided by engineer. */ "Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "消息、文件和通话受到 **端到端加密** 的保护,具有完全正向保密、否认和闯入恢复。"; @@ -2853,24 +3330,30 @@ /* time unit */ "months" = "月"; +/* swipe action */ +"More" = "更多"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "更多改进即将推出!"; /* No comment provided by engineer. */ "More reliable network connection." = "更可靠的网络连接。"; +/* No comment provided by engineer. */ +"More reliable notifications" = "更可靠的通知"; + /* item status description */ "Most likely this connection is deleted." = "此连接很可能已被删除。"; /* No comment provided by engineer. */ "Multiple chat profiles" = "多个聊天资料"; -/* No comment provided by engineer. */ -"mute" = "静音"; - -/* swipe action */ +/* notification label action */ "Mute" = "静音"; +/* notification label action */ +"Mute all" = "全部静音"; + /* No comment provided by engineer. */ "Muted when inactive!" = "不活动时静音!"; @@ -2883,21 +3366,30 @@ /* No comment provided by engineer. */ "Network connection" = "网络连接"; +/* No comment provided by engineer. */ +"Network decentralization" = "网络去中心化"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "网络问题 - 消息在多次尝试发送后过期。"; /* No comment provided by engineer. */ "Network management" = "网络管理"; +/* No comment provided by engineer. */ +"Network operator" = "网络运营方"; + /* No comment provided by engineer. */ "Network settings" = "网络设置"; /* No comment provided by engineer. */ "Network status" = "网络状态"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "从不"; +/* token status text */ +"New" = "新"; + /* No comment provided by engineer. */ "New chat" = "新聊天"; @@ -2916,6 +3408,9 @@ /* No comment provided by engineer. */ "New display name" = "新显示名"; +/* notification */ +"New events" = "新事件"; + /* No comment provided by engineer. */ "New in %@" = "%@ 的新内容"; @@ -2937,6 +3432,15 @@ /* No comment provided by engineer. */ "New passphrase…" = "新密码……"; +/* No comment provided by engineer. */ +"New server" = "新服务器"; + +/* No comment provided by engineer. */ +"New SOCKS credentials will be used every time you start the app." = "每次启动应用都会使用新的 SOCKS 凭据。"; + +/* No comment provided by engineer. */ +"New SOCKS credentials will be used for each server." = "每个服务器都会使用新的 SOCKS 凭据。"; + /* pref value */ "no" = "否"; @@ -2946,6 +3450,15 @@ /* Authentication unavailable */ "No app password" = "没有应用程序密码"; +/* No comment provided by engineer. */ +"No chats" = "无聊天"; + +/* No comment provided by engineer. */ +"No chats found" = "找不到聊天"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "列表 %@ 中无聊天"; + /* No comment provided by engineer. */ "No contacts selected" = "未选择联系人"; @@ -2976,9 +3489,24 @@ /* No comment provided by engineer. */ "No info, try to reload" = "无信息,尝试重新加载"; +/* servers error */ +"No media & file servers." = "无媒体和文件服务器。"; + +/* No comment provided by engineer. */ +"No message" = "无消息"; + +/* servers error */ +"No message servers." = "无消息服务器。"; + /* No comment provided by engineer. */ "No network connection" = "无网络连接"; +/* No comment provided by engineer. */ +"No permission to record speech" = "无录音权限"; + +/* No comment provided by engineer. */ +"No permission to record video" = "无录像权限"; + /* No comment provided by engineer. */ "No permission to record voice message" = "没有录制语音消息的权限"; @@ -2988,24 +3516,57 @@ /* No comment provided by engineer. */ "No received or sent files" = "未收到或发送文件"; +/* servers error */ +"No servers for private message routing." = "无私密消息路由服务器。"; + +/* servers error */ +"No servers to receive files." = "无文件接收服务器。"; + +/* servers error */ +"No servers to receive messages." = "无消息接收服务器。"; + +/* servers error */ +"No servers to send files." = "无文件发送服务器。"; + /* copied message info in history */ "no text" = "无文本"; +/* alert title */ +"No token!" = "无 token!"; + +/* No comment provided by engineer. */ +"No unread chats" = "没有未读聊天"; + /* No comment provided by engineer. */ "No user identifiers." = "没有用户标识符。"; /* No comment provided by engineer. */ "Not compatible!" = "不兼容!"; +/* No comment provided by engineer. */ +"Notes" = "附注"; + /* No comment provided by engineer. */ "Nothing selected" = "未选中任何内容"; +/* alert title */ +"Nothing to forward!" = "无可转发!"; + /* No comment provided by engineer. */ "Notifications" = "通知"; /* No comment provided by engineer. */ "Notifications are disabled!" = "通知被禁用!"; +/* alert title */ +"Notifications error" = "通知错误"; + +/* No comment provided by engineer. */ +"Notifications privacy" = "通知隐私"; + +/* alert title */ +"Notifications status" = "通知状态"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "现在管理员可以:\n- 删除成员的消息。\n- 禁用成员(“观察员”角色)"; @@ -3013,8 +3574,9 @@ "observer" = "观察者"; /* enabled status - group pref value - time to disappear */ +group pref value +member criteria value +time to disappear */ "off" = "关闭"; /* blur media */ @@ -3026,7 +3588,8 @@ /* feature offered item */ "offered %@: %@" = "已提供 %1$@:%2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "好的"; /* No comment provided by engineer. */ @@ -3050,6 +3613,9 @@ /* No comment provided by engineer. */ "Onion hosts will not be used." = "将不会使用 Onion 主机。"; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "仅聊天所有人可更改首选项。"; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "只有客户端设备存储用户资料、联系人、群组和**双层端到端加密**发送的消息。"; @@ -3065,6 +3631,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "只有群主可以启用语音信息。"; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "仅发送人和moderators能看到"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "只有你和moderators能看到"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "只有您可以添加消息回应。"; @@ -3095,15 +3667,21 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "只有您的联系人可以发送语音消息。"; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "打开"; +/* No comment provided by engineer. */ +"Open changes" = "打开更改"; + /* No comment provided by engineer. */ "Open chat" = "打开聊天"; /* authentication reason */ "Open chat console" = "打开聊天控制台"; +/* No comment provided by engineer. */ +"Open conditions" = "打开条款"; + /* No comment provided by engineer. */ "Open group" = "打开群"; @@ -3116,6 +3694,15 @@ /* No comment provided by engineer. */ "Opening app…" = "正在打开应用程序…"; +/* No comment provided by engineer. */ +"Operator" = "运营方"; + +/* alert title */ +"Operator server" = "运营方服务器"; + +/* No comment provided by engineer. */ +"Or import archive file" = "或者导入或者导入压缩文件"; + /* No comment provided by engineer. */ "Or paste archive link" = "或粘贴存档链接"; @@ -3128,6 +3715,12 @@ /* No comment provided by engineer. */ "Or show this code" = "或者显示此码"; +/* No comment provided by engineer. */ +"Or to share privately" = "或者私下分享"; + +/* No comment provided by engineer. */ +"Organize chats into lists" = "将聊天组织到列表"; + /* No comment provided by engineer. */ "other" = "其他"; @@ -3158,6 +3751,9 @@ /* No comment provided by engineer. */ "Passcode set!" = "密码已设置!"; +/* No comment provided by engineer. */ +"Password" = "密码"; + /* No comment provided by engineer. */ "Password to show" = "显示密码"; @@ -3269,9 +3865,15 @@ /* No comment provided by engineer. */ "Privacy & security" = "隐私和安全"; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "隐私政策和使用条款。"; + /* No comment provided by engineer. */ "Privacy redefined" = "重新定义隐私"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "服务器运营方无法访问私密聊天、群组和你的联系人。"; + /* No comment provided by engineer. */ "Private filenames" = "私密文件名"; @@ -3480,7 +4082,7 @@ "Reduced battery usage" = "减少电池使用量"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "拒绝"; /* No comment provided by engineer. */ @@ -3607,19 +4209,22 @@ "Reveal" = "揭示"; /* No comment provided by engineer. */ -"Revoke" = "撤销"; - -/* cancel file action */ -"Revoke file" = "撤销文件"; +"Review conditions" = "审阅条款"; /* No comment provided by engineer. */ -"Revoke file?" = "撤销文件?"; +"Revoke" = "吊销"; + +/* cancel file action */ +"Revoke file" = "吊销文件"; + +/* No comment provided by engineer. */ +"Revoke file?" = "吊销文件?"; /* No comment provided by engineer. */ "Role" = "角色"; /* No comment provided by engineer. */ -"Run chat" = "运行聊天程序"; +"Run chat" = "运行聊天"; /* No comment provided by engineer. */ "Safely receive files" = "安全接收文件"; @@ -3628,7 +4233,7 @@ "Safer groups" = "更安全的群组"; /* alert button - chat item action */ +chat item action */ "Save" = "保存"; /* alert button */ @@ -3649,6 +4254,9 @@ /* No comment provided by engineer. */ "Save group profile" = "保存群组资料"; +/* No comment provided by engineer. */ +"Save list" = "保存列表"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "保存密码并打开聊天"; @@ -3670,6 +4278,9 @@ /* No comment provided by engineer. */ "Save welcome message?" = "保存欢迎信息?"; +/* alert title */ +"Save your profile?" = "保存您的个人资料?"; + /* No comment provided by engineer. */ "saved" = "已保存"; @@ -3688,6 +4299,9 @@ /* No comment provided by engineer. */ "Saved WebRTC ICE servers will be removed" = "已保存的WebRTC ICE服务器将被删除"; +/* No comment provided by engineer. */ +"Saving %lld messages" = "正在保存 %lld 条消息"; + /* No comment provided by engineer. */ "Scale" = "规模"; @@ -3965,7 +4579,7 @@ "Shape profile images" = "改变个人资料图形状"; /* alert action - chat item action */ +chat item action */ "Share" = "分享"; /* No comment provided by engineer. */ @@ -4028,6 +4642,18 @@ /* No comment provided by engineer. */ "SimpleX Address" = "SimpleX 地址"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "可以通过任何消息应用安全分享 SimpleX 地址和一次性链接。"; + +/* No comment provided by engineer. */ +"SimpleX address or 1-time link?" = "SimpleX 地址或一次性链接?"; + +/* simplex link type */ +"SimpleX channel link" = "SimpleX 频道链接"; + +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat 与 Flux 达成了协议,将由 Flux 控制的服务器纳入 SimpleX 应用。"; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "SimpleX Chat 的安全性 由 Trail of Bits 审核。"; @@ -4035,10 +4661,10 @@ "SimpleX contact address" = "SimpleX 联系地址"; /* notification */ -"SimpleX encrypted message or connection event" = "SimpleX 加密消息或连接项目"; +"SimpleX encrypted message or connection event" = "SimpleX 加密的消息或连接事件"; /* simplex link type */ -"SimpleX group link" = "SimpleX 群组链接"; +"SimpleX group link" = "SimpleX 群链接"; /* chat feature */ "SimpleX links" = "SimpleX 链接"; @@ -4064,6 +4690,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX 一次性邀请"; +/* No comment provided by engineer. */ +"SimpleX protocols reviewed by Trail of Bits." = "SimpleX 协议由 Trail of Bits 审阅。"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "简化的隐身模式"; @@ -4085,6 +4714,9 @@ /* blur media */ "Soft" = "软"; +/* No comment provided by engineer. */ +"Some app settings were not migrated." = "部分应用设置未被迁移。"; + /* No comment provided by engineer. */ "Some file(s) were not exported:" = "某些文件未导出:"; @@ -4220,7 +4852,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "临时文件错误"; /* server test failure */ @@ -4292,9 +4924,6 @@ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "旧数据库在迁移过程中没有被移除,可以删除。"; -/* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "该资料仅与您的联系人共享。"; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "我们错过的第二个\"√\"!✅"; @@ -4508,10 +5137,7 @@ /* authentication reason */ "Unlock app" = "解锁应用程序"; -/* No comment provided by engineer. */ -"unmute" = "取消静音"; - -/* swipe action */ +/* notification label action */ "Unmute" = "取消静音"; /* No comment provided by engineer. */ @@ -5070,10 +5696,10 @@ "Your profile **%@** will be shared." = "您的个人资料 **%@** 将被共享。"; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。"; +"Your profile is stored on your device and only shared with your contacts." = "该资料仅与您的联系人共享。"; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "您的资料、联系人和发送的消息存储在您的设备上。"; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。"; /* No comment provided by engineer. */ "Your random profile" = "您的随机资料"; @@ -5087,6 +5713,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "您的 SimpleX 地址"; -/* No comment provided by engineer. */ -"Your SMP servers" = "您的 SMP 服务器"; - diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 250616ea5c..8fc9d104d2 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -85,6 +85,7 @@ android { "en", "ar", "bg", + "ca", "cs", "de", "es", diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index bb6a6f8f8a..0470977bcd 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -77,8 +77,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -179,5 +204,16 @@ android:name=".SimplexService$AutoRestartReceiver" android:enabled="true" android:exported="false" /> + + + + + + + + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 2d2829f1f2..bacdfe70af 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -229,5 +229,5 @@ fun isMediaIntent(intent: Intent): Boolean = // val str: String = """ // """.trimIndent() // -// println(json.decodeFromString(str)) +// println(json.decodeFromString(str)) //} 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 ee259a98d0..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 @@ -71,6 +69,7 @@ class SimplexApp: Application(), LifecycleEventObserver { context = this initHaskell(packageName) initMultiplatform() + reconfigureBroadcastReceivers() runMigrations() tmpDir.deleteRecursively() tmpDir.mkdir() @@ -93,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())) @@ -106,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()) } } @@ -216,6 +215,7 @@ class SimplexApp: Application(), LifecycleEventObserver { appPrefs.backgroundServiceNoticeShown.set(false) } SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) + SimplexService.AppUpdateReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) CoroutineScope(Dispatchers.Default).launch { if (mode == NotificationsMode.SERVICE) { SimplexService.start() @@ -371,4 +371,10 @@ class SimplexApp: Application(), LifecycleEventObserver { override val androidApiLevel: Int get() = Build.VERSION.SDK_INT } } + + // Make sure that receivers enabled state is in actual state (same as in prefs) + private fun reconfigureBroadcastReceivers() { + val mode = appPrefs.notificationsMode.get() + SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) + SimplexService.AppUpdateReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)} } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 6ca8dd43a0..ad86759ba9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -249,6 +249,29 @@ class SimplexService: Service() { } } + // restart on app update + class AppUpdateReceiver: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // If notification service is enabled and battery optimization is disabled, restart the service on app update + if (SimplexApp.context.allowToStartServiceAfterAppExit()) { + Log.d(TAG, "AppUpdateReceiver: onReceive called") + scheduleStart(context) + } + } + + companion object { + fun toggleReceiver(enable: Boolean) { + Log.d(TAG, "AppUpdateReceiver: toggleReceiver enabled: $enable") + val component = ComponentName(BuildConfig.APPLICATION_ID, AppUpdateReceiver::class.java.name) + SimplexApp.context.packageManager.setComponentEnabledSetting( + component, + if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + } + } + class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) { override suspend fun doWork(): Result { val id = this.id @@ -694,6 +717,7 @@ class SimplexService: Service() { } ChatController.appPrefs.notificationsMode.set(NotificationsMode.OFF) StartReceiver.toggleReceiver(false) + AppUpdateReceiver.toggleReceiver(false) androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) MessagesFetcherWorker.cancelAll() safeStopService() diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 5b7f89f2df..345a75b1e7 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -154,6 +154,7 @@ buildConfig { buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}") buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") + buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt index dc6c53ecbc..d9a5fb59e3 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -30,10 +30,31 @@ internal class Cryptor: CryptorInterface { } return null } - val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull() + + try { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + return String(cipher.doFinal(data)) + } catch (e: Throwable) { + Log.e(TAG, "cipher.init: ${e.stackTraceToString()}") + val randomPassphrase = appPreferences.initialRandomDBPassphrase.get() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_reading_passphrase), + text = generalGetString(if (randomPassphrase) { + MR.strings.restore_passphrase_can_not_be_read_desc + } else { + MR.strings.restore_passphrase_can_not_be_read_enter_manually_desc + } + ) + .plus("\n\n").plus(e.stackTraceToString()) + ) + if (randomPassphrase) { + // do not allow to override initial random passphrase in case of such error + throw e + } + return null + } } override fun encryptText(text: String, alias: String): Pair { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index fc323f6ffd..4f47fda130 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -101,13 +101,13 @@ actual fun GrayU8.toImageBitmap(): ImageBitmap = ConvertBitmap.grayToBitmap(this actual fun ImageBitmap.hasAlpha(): Boolean = hasAlpha -actual fun ImageBitmap.addLogo(): ImageBitmap = asAndroidBitmap().applyCanvas { - val radius = (width * 0.16f) / 2 +actual fun ImageBitmap.addLogo(size: Float): ImageBitmap = asAndroidBitmap().applyCanvas { + val radius = (width * size) / 2 val paint = android.graphics.Paint() paint.color = android.graphics.Color.WHITE drawCircle(width / 2f, height / 2f, radius, paint) val logo = androidAppContext.resources.getDrawable(R.drawable.icon_foreground_android_common, null).toBitmap() - val logoSize = (width * 0.24).toInt() + val logoSize = (width * size * 1.5).toInt() translate((width - logoSize) / 2f, (height - logoSize) / 2f) drawBitmap(logo, null, android.graphics.Rect(0, 0, logoSize, logoSize), null) }.asImageBitmap() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 7b820aa67e..4f48ccca52 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -15,10 +15,12 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.LayoutDirection @@ -40,7 +42,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import java.lang.reflect.Field import java.net.URI @@ -49,15 +50,16 @@ import java.net.URI actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + disabledText: String?, sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, - userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { val cs = composeState.value @@ -117,6 +119,13 @@ actual fun PlatformTextField( } return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + val start = minOf(text.length, minOf(selStart, selEnd)) + val end = minOf(text.length, maxOf(selStart, selEnd)) + onMessageChange(ComposeMessage(text.toString(), TextRange(start, end))) + super.onSelectionChanged(start, end) + } } editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) editText.maxLines = 16 @@ -126,7 +135,8 @@ actual fun PlatformTextField( editText.background = ColorDrawable(Color.Transparent.toArgb()) editText.textDirection = if (isRtl) EditText.TEXT_DIRECTION_LOCALE else EditText.TEXT_DIRECTION_ANY_RTL editText.setPaddingRelative(paddingStart, paddingTop, paddingEnd, paddingBottom) - editText.setText(cs.message) + editText.setText(cs.message.text) + editText.setSelection(cs.message.selection.start, cs.message.selection.end) editText.hint = placeholder editText.setHintTextColor(hintColor.toArgb()) if (Build.VERSION.SDK_INT >= 29) { @@ -149,9 +159,10 @@ actual fun PlatformTextField( } editText.doOnTextChanged { text, _, _, _ -> if (!composeState.value.inProgress) { - onMessageChange(text.toString()) - } else if (text.toString() != composeState.value.message) { - editText.setText(composeState.value.message) + onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(editText.selectionStart, editText.selectionEnd), maxOf(editText.selectionStart, editText.selectionEnd)))) + } else if (text.toString() != composeState.value.message.text) { + editText.setText(composeState.value.message.text) + editText.setSelection(composeState.value.message.selection.start, composeState.value.message.selection.end) } } editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") } @@ -167,10 +178,9 @@ actual fun PlatformTextField( it.textSize = textStyle.value.fontSize.value * appPrefs.fontScale.get() it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview it.isFocusableInTouchMode = it.isFocusable - if (cs.message != it.text.toString()) { - it.setText(cs.message) - // Set cursor to the end of the text - it.setSelection(it.text.length) + if (cs.message.text != it.text.toString() || cs.message.selection.start != it.selectionStart || cs.message.selection.end != it.selectionEnd) { + it.setText(cs.message.text) + it.setSelection(cs.message.selection.start, cs.message.selection.end) } if (showKeyboard) { it.requestFocus() @@ -186,16 +196,16 @@ actual fun PlatformTextField( showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress } if (composeState.value.preview is ComposePreview.VoicePreview) { - ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) - } else if (userIsObserver) { - ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) + ComposeOverlay(generalGetString(MR.strings.voice_message_send_text), textStyle, padding) + } else if (disabledText != null) { + ComposeOverlay(disabledText, textStyle, padding) } } @Composable -private fun ComposeOverlay(textId: StringResource, textStyle: MutableState, padding: PaddingValues) { +private fun ComposeOverlay(text: String, textStyle: MutableState, padding: PaddingValues) { Text( - generalGetString(textId), + text, Modifier.padding(padding), color = MaterialTheme.colors.secondary, style = textStyle.value.copy(fontStyle = FontStyle.Italic) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt index b3d8e9b52f..0d07de28f8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt @@ -95,8 +95,10 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, + maxHeight: State?, + containerAlignment: Alignment, content: LazyListScope.() -> Unit -) { + ) { val state = state ?: rememberLazyListState() LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) { content() 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/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt index 54e3061d25..a09ca2792b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt @@ -91,7 +91,7 @@ fun UserPickerUserBox( ProfileImageForActiveCall(size = USER_PICKER_IMAGE_SIZE, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant) if (userInfo.unreadCount > 0 && !userInfo.user.activeUser) { - unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs, false) + userUnreadBadge(userInfo.unreadCount, userInfo.user.showNtfs, false) } } val user = userInfo.user 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 ba1eda8a7c..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 @@ -194,7 +194,7 @@ fun MainScreen() { OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) OnboardingStage.Step3_ChooseServerOperators -> { val modalData = remember { ModalData() } - modalData.ChooseServerOperators(true) + modalData.OnboardingConditionsView() if (appPlatform.isDesktop) { ModalManager.fullscreen.showInView() } @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, reportsView = false, onComposed = onComposed) + ChatView(chatsCtx = chatModel.chatsContext, currentChatId, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId, reportsView = false) {} + 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 4eb0b350cb..270b3a73b2 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 @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration +import chat.simplex.common.model.MsgFilter.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -37,6 +38,7 @@ import java.net.URI import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* +import java.util.concurrent.atomic.AtomicLong import kotlin.collections.ArrayList import kotlin.random.Random import kotlin.time.* @@ -64,14 +66,16 @@ object ChatModel { // current chat 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 val deletedChats = mutableStateOf>>(emptyList()) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) + val membersLoaded = mutableStateOf(false) // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -167,36 +171,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 { @@ -245,15 +219,12 @@ object ChatModel { } } - if (activeChatTagFilter.value is ActiveFilter.PresetTag && - (newPresetTags[(activeChatTagFilter.value as ActiveFilter.PresetTag).tag] ?: 0) == 0) { - activeChatTagFilter.value = null - } - presetTags.clear() presetTags.putAll(newPresetTags) unreadTags.clear() unreadTags.putAll(newUnreadTags) + + clearActiveChatFilterIfNeeded() } fun updateChatFavorite(favorite: Boolean, wasFavorite: Boolean) { @@ -263,9 +234,7 @@ object ChatModel { presetTags[PresetTagKind.FAVORITES] = (count ?: 0) + 1 } else if (!favorite && wasFavorite && count != null) { presetTags[PresetTagKind.FAVORITES] = maxOf(0, count - 1) - if (activeChatTagFilter.value == ActiveFilter.PresetTag(PresetTagKind.FAVORITES) && (presetTags[PresetTagKind.FAVORITES] ?: 0) == 0) { - activeChatTagFilter.value = null - } + clearActiveChatFilterIfNeeded() } } @@ -286,6 +255,7 @@ object ChatModel { } } } + clearActiveChatFilterIfNeeded() } fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { @@ -325,37 +295,40 @@ 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 secondaryContextFilter: SecondaryContextFilter?) { 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 fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } + val contentTag: MsgContentTag? = + when (secondaryContextFilter) { + null -> null + is SecondaryContextFilter.GroupChatScopeContext -> null + is SecondaryContextFilter.MsgContentTagContext -> secondaryContextFilter.contentTag + } + + val groupScopeInfo: GroupChatScopeInfo? = + when (secondaryContextFilter) { + null -> null + is SecondaryContextFilter.GroupChatScopeContext -> secondaryContextFilter.groupScopeInfo + is SecondaryContextFilter.MsgContentTagContext -> null + } + + val isUserSupportChat: Boolean = + when (groupScopeInfo) { + null -> false + is GroupChatScopeInfo.MemberSupport -> groupScopeInfo.groupMember_ == null + } + suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0) @@ -388,6 +361,8 @@ object ChatModel { ) ) } + } else if (currentCInfo is ChatInfo.Group && newCInfo is ChatInfo.Group && newCInfo.groupChatScope != null) { + newCInfo = newCInfo.copy(groupInfo = newCInfo.groupInfo, groupChatScope = null) } chats[i] = chats[i].copy(chatInfo = newCInfo) } @@ -410,7 +385,7 @@ object ChatModel { updateContact(rhId, updatedContact) } - suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) + suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null)) private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { if (hasChat(rhId, cInfo.id)) { @@ -441,145 +416,191 @@ 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) { + // updates membersRequireAttention + updateChatInfo(rhId, cInfo) // mark chat non deleted if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { val updatedContact = cInfo.contact.copy(chatDeleted = false) updateContact(rhId, updatedContact) } - // update previews + // update chat list val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { - chat = chats[i] - val newPreviewItem = when (cInfo) { - is ChatInfo.Group -> { - val currentPreviewItem = chat.chatItems.firstOrNull() - if (currentPreviewItem != null) { - if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { - cItem + chat = chatsContext.chats[i] + // update preview (for chat from main scope to show new items for invitee in pending status) + if (cInfo.groupChatScope() == null || cInfo.groupInfo_?.membership?.memberPending == true) { + val newPreviewItem = when (cInfo) { + is ChatInfo.Group -> { + val currentPreviewItem = chat.chatItems.firstOrNull() + if (currentPreviewItem != null) { + if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { + cItem + } else { + currentPreviewItem + } } else { - currentPreviewItem + cItem } - } else { - cItem } - } - else -> cItem - } - val wasUnread = chat.unreadTag - chats[i] = chat.copy( - chatItems = arrayListOf(newPreviewItem), - chatStats = - if (cItem.meta.itemStatus is CIStatus.RcvNew) { - increaseUnreadCounter(rhId, currentUser.value!!) - chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1) - } - else - chat.chatStats - ) - updateChatTagReadNoContentTag(chats[i], wasUnread) + else -> cItem + } + val wasUnread = chat.unreadTag + chatsContext.chats[i] = chat.copy( + chatItems = arrayListOf(newPreviewItem), + chatStats = + if (cItem.meta.itemStatus is CIStatus.RcvNew) { + increaseUnreadCounter(rhId, currentUser.value!!) + chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) + } else + chat.chatStats + ) + updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread) + } + // pop chat if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chats[i], 0) + reorderChat(chatsContext.chats[i], 0) } else { popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) } } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + if (cInfo.groupChatScope() == null) { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = emptyList())) + } } + // add to current scope withContext(Dispatchers.Main) { - // add to current chat - if (chatId.value == cInfo.id) { + if (chatItemBelongsToScope(cInfo, cItem)) { // 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) } } } } } - - suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - val res: Boolean - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(cItem)) - if (pItem.isRcvNew && !cItem.isRcvNew) { - // status changed from New to Read, update counter - decreaseCounterInChatNoContentTag(rhId, cInfo.id) + + private fun chatItemBelongsToScope(cInfo: ChatInfo, cItem: ChatItem): Boolean = + when (secondaryContextFilter) { + null -> + chatId.value == cInfo.id && cInfo.groupChatScope() == null + is SecondaryContextFilter.GroupChatScopeContext -> { + val cInfoScope = cInfo.groupChatScope() + if (cInfoScope != null) { + chatId.value == cInfo.id && sameChatScope(cInfoScope, secondaryContextFilter.groupScopeInfo.toChatScope()) + } else { + false } } - res = false - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) - res = true + is SecondaryContextFilter.MsgContentTagContext -> + chatId.value == cInfo.id && cItem.isReport } - return withContext(Dispatchers.Main) { - // update current chat - if (chatId.value == cInfo.id) { + + suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { + var itemAdded = false + // update chat list + if (cInfo.groupChatScope() == null) { + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(cItem)) + if (pItem.isRcvNew && !cItem.isRcvNew) { + // status changed from New to Read, update counter + decreaseCounterInPrimaryContext(rhId, cInfo.id) + } + } + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + itemAdded = true + } + } + // update current scope + withContext(Dispatchers.Main) { + if (chatItemBelongsToScope(cInfo, cItem)) { if (cItem.isDeletedContent || cItem.meta.itemDeleted != null) { AudioPlayer.stop(cItem) } val items = chatItems.value val itemIndex = items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { - items[itemIndex] = cItem - false - } else { - val status = chatItemStatuses.remove(cItem.id) - val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { - cItem.copy(meta = cItem.meta.copy(itemStatus = status)) + val oldStatus = items[itemIndex].meta.itemStatus + val newStatus = cItem.meta.itemStatus + val ci = if (shouldKeepOldSndCIStatus(oldStatus, newStatus)) { + cItem.copy(meta = cItem.meta.copy(itemStatus = oldStatus)) } else { cItem } - chatItems.addAndNotify(ci, contentTag) - true + items[itemIndex] = ci + } else { + addToChatItems(cItem) + itemAdded = true } - } else { - res } } + return itemAdded } - suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) { withContext(Dispatchers.Main) { - if (chatId.value == cInfo.id) { + if (chatItemBelongsToScope(cInfo, cItem)) { val items = chatItems.value - val itemIndex = items.indexOfFirst { it.id == cItem.id } + val itemIndex = atIndex ?: items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { items[itemIndex] = cItem } - } else if (status != null) { - chatItemStatuses[cItem.id] = status } } } fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { - if (cItem.isRcvNew) { - decreaseCounterInChatNoContentTag(rhId, cInfo.id) - } - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) + // update chat list + if (cInfo.groupChatScope() == null) { + if (cItem.isRcvNew) { + decreaseCounterInPrimaryContext(rhId, cInfo.id) + } + // update preview + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) + } } } - // remove from current chat - if (chatId.value == cInfo.id) { + // remove from current scope + if (chatItemBelongsToScope(cInfo, cItem)) { chatItems.removeAllAndNotify { // We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted // (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id) @@ -590,24 +611,67 @@ object ChatModel { } } + suspend fun removeMemberItems(rhId: Long?, removedMember: GroupMember, byMember: GroupMember, groupInfo: GroupInfo) { + fun removedUpdatedItem(item: ChatItem): ChatItem? { + val newContent = when { + item.chatDir is CIDirection.GroupSnd && removedMember.groupMemberId == groupInfo.membership.groupMemberId -> CIContent.SndModerated + item.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember.groupMemberId == removedMember.groupMemberId -> CIContent.RcvModerated + else -> return null + } + val updatedItem = item.copy( + meta = item.meta.copy(itemDeleted = CIDeleted.Moderated(Clock.System.now(), byGroupMember = byMember)), + content = if (groupInfo.fullGroupPreferences.fullDelete.on) newContent else item.content + ) + if (item.isActiveReport) { + decreaseGroupReportsCounter(rhId, groupInfo.id) + } + return updatedItem + } + + // this should not happen, only another member can "remove" user, user can only "leave" (another event). + if (byMember.groupMemberId == groupInfo.membership.groupMemberId) { + Log.d(TAG, "exiting removeMemberItems") + return + } + val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review + if (chatId.value == groupInfo.id) { + for (i in 0 until chatItems.value.size) { + val updatedItem = removedUpdatedItem(chatItems.value[i]) + if (updatedItem != null) { + updateChatItem(cInfo, updatedItem, atIndex = i) + } + } + } else { + val i = getChatIndex(rhId, groupInfo.id) + val chat = chats[i] + if (chat.chatItems.isNotEmpty()) { + val updatedItem = removedUpdatedItem(chat.chatItems[0]) + if (updatedItem != null) { + chats.value[i] = chat.copy(chatItems = listOf(updatedItem)) + } + } + } + } + fun clearChat(rhId: Long?, cInfo: ChatInfo) { // clear preview val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) + val chatBefore = chats[i] chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) - markChatTagRead(chats[i]) + markChatTagRead(chatBefore) } // clear current chat if (chatId.value == cInfo.id) { - chatItemStatuses.clear() chatItems.clearAndNotify() } } - 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() @@ -617,8 +681,8 @@ object ChatModel { subject .throttleLatest(2000) .collect { - withChats(contentTag) { - chats.replaceAll(popCollectedChats()) + withContext(Dispatchers.Main) { + chatsCtx.chats.replaceAll(popCollectedChats()) } } } @@ -656,7 +720,7 @@ object ChatModel { } fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List? = null) { - val markedRead = markItemsReadInCurrentChat(id, itemIds) + val (markedRead, mentionsMarkedRead) = markItemsReadInCurrentChat(id, itemIds) // update preview val chatIdx = getChatIndex(remoteHostId, id) if (chatIdx >= 0) { @@ -665,17 +729,19 @@ object ChatModel { if (lastId != null) { val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 + val unreadMentions = if (itemIds != null) chat.chatStats.unreadMentions - mentionsMarkedRead else 0 decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( - chatStats = chat.chatStats.copy(unreadCount = unreadCount) + chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions) ) - updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) + updateChatTagReadInPrimaryContext(chats[chatIdx], wasUnread) } } } - private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Int { + private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Pair { var markedRead = 0 + var mentionsMarkedRead = 0 if (chatId.value == id) { val items = chatItems.value var i = items.lastIndex @@ -693,6 +759,9 @@ object ChatModel { } markedReadIds.add(item.id) markedRead++ + if (item.meta.userMention) { + mentionsMarkedRead++ + } if (itemIds != null) { itemIdsFromRange.remove(item.id) // already set all needed items as read, can finish the loop @@ -701,14 +770,14 @@ object ChatModel { } i-- } - chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) + chatState.itemsRead(if (itemIds != null) markedReadIds else null, items) } - return markedRead + return markedRead to mentionsMarkedRead } - private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { + private fun decreaseCounterInPrimaryContext(rhId: Long?, chatId: ChatId) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return @@ -722,7 +791,7 @@ object ChatModel { unreadCount = unreadCount, ) ) - updateChatTagReadNoContentTag(chats[chatIndex], wasUnread) + updateChatTagReadInPrimaryContext(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { @@ -742,6 +811,11 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { + if (groupMembers.value.isNotEmpty() && groupMembers.value.firstOrNull()?.groupId != groupInfo.groupId) { + // stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn + groupMembers.value = emptyList() + groupMembersIndexes.value = emptyMap() + } val memberIndex = groupMembersIndexes.value[member.groupMemberId] val updated = chatItems.value.map { // Take into account only specific changes, not all. Other member updates are not important and can be skipped @@ -786,16 +860,16 @@ object ChatModel { } fun increaseUnreadCounter(rhId: Long?, user: UserLike) { - changeUnreadCounterNoContentTag(rhId, user, 1) + changeUnreadCounterInPrimaryContext(rhId, user, 1) } fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { - changeUnreadCounterNoContentTag(rhId, user, -by) + changeUnreadCounterInPrimaryContext(rhId, user, -by) } - private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) { + private fun changeUnreadCounterInPrimaryContext(rhId: Long?, user: UserLike, by: Int) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } if (i != -1) { @@ -803,9 +877,9 @@ object ChatModel { } } - fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) { + fun updateChatTagReadInPrimaryContext(chat: Chat, wasUnread: Boolean) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val tags = chat.chatInfo.chatTags ?: return val nowUnread = chat.unreadTag @@ -815,21 +889,21 @@ object ChatModel { unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 } } else if (!nowUnread && wasUnread) { - markChatTagReadNoContentTag_(chat, tags) + markChatTagReadInPrimaryContext_(chat, tags) } } fun markChatTagRead(chat: Chat) { if (chat.unreadTag) { chat.chatInfo.chatTags?.let { tags -> - markChatTagReadNoContentTag_(chat, tags) + markChatTagReadInPrimaryContext_(chat, tags) } } } - private fun markChatTagReadNoContentTag_(chat: Chat, tags: List) { + private fun markChatTagReadInPrimaryContext_(chat: Chat, tags: List) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return for (tag in tags) { val count = unreadTags[tag] @@ -843,8 +917,8 @@ object ChatModel { changeGroupReportsCounter(rhId, chatId, 1) } - fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { - changeGroupReportsCounter(rhId, chatId, -1) + fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 1) { + changeGroupReportsCounter(rhId, chatId, -by) } private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { @@ -861,16 +935,26 @@ object ChatModel { val wasReportsCount = chat.chatStats.reportsCount val nowReportsCount = chats[i].chatStats.reportsCount val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0 - changeGroupReportsTagNoContentTag(by) + changeGroupReportsTagInPrimaryContext(by) } } - private fun changeGroupReportsTagNoContentTag(by: Int = 0) { - if (by == 0 || contentTag != null) return - presetTags[PresetTagKind.GROUP_REPORTS] = (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by + private fun changeGroupReportsTagInPrimaryContext(by: Int = 0) { + if (by == 0 || secondaryContextFilter != null) return + presetTags[PresetTagKind.GROUP_REPORTS] = kotlin.math.max(0, (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by) + clearActiveChatFilterIfNeeded() } } + fun clearActiveChatFilterIfNeeded() { + val clear = when(val f = activeChatTagFilter.value) { + is ActiveFilter.PresetTag -> (presetTags[f.tag] ?: 0) == 0 + is ActiveFilter.UserTag -> userTags.value.none { it.chatTagId == f.tag.chatTagId } + is ActiveFilter.Unread, null -> false + } + if (clear) activeChatTagFilter.value = null + } + fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( @@ -898,17 +982,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() } } } @@ -980,9 +1064,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 } } @@ -993,9 +1077,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 @@ -1046,18 +1130,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 ) @@ -1070,6 +1145,28 @@ enum class ChatType(val type: String) { ContactConnection(":"); } +sealed class GroupChatScope { + class MemberSupport(val groupMemberId_: Long?): GroupChatScope() +} + +fun sameChatScope(scope1: GroupChatScope, scope2: GroupChatScope) = + scope1 is GroupChatScope.MemberSupport + && scope2 is GroupChatScope.MemberSupport + && scope1.groupMemberId_ == scope2.groupMemberId_ + +@Serializable +sealed class GroupChatScopeInfo { + @Serializable @SerialName("memberSupport") data class MemberSupport(val groupMember_: GroupMember?) : GroupChatScopeInfo() + + fun toChatScope(): GroupChatScope = + when (this) { + is MemberSupport -> when (groupMember_) { + null -> GroupChatScope.MemberSupport(groupMemberId_ = null) + else -> GroupChatScope.MemberSupport(groupMemberId_ = groupMember_.groupMemberId) + } + } +} + @Serializable data class User( val remoteHostId: Long?, @@ -1175,8 +1272,6 @@ interface SomeChat { val apiId: Long val ready: Boolean val chatDeleted: Boolean - val sendMsgEnabled: Boolean - val ntfsEnabled: Boolean val incognito: Boolean fun featureEnabled(feature: ChatFeature): Boolean val timedMessagesTTL: Int? @@ -1200,32 +1295,27 @@ data class Chat( else -> false } - val userIsObserver: Boolean get() = when(chatInfo) { - is ChatInfo.Group -> { - val m = chatInfo.groupInfo.membership - m.memberActive && m.memberRole == GroupMemberRole.Observer - } - else -> false + val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) { + All -> chatStats.unreadChat || chatStats.unreadCount > 0 + Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0 + else -> chatStats.unreadChat } - val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) - val id: String get() = chatInfo.id + val supportUnreadCount: Int get() = when (chatInfo) { + is ChatInfo.Group -> + if (chatInfo.groupInfo.canModerate) { + chatInfo.groupInfo.membersRequireAttention + } else { + chatInfo.groupInfo.membership.supportChat?.unread ?: 0 + } + else -> 0 + } + fun groupFeatureEnabled(feature: GroupFeature): Boolean = if (chatInfo is ChatInfo.Group) { - val groupInfo = chatInfo.groupInfo - val p = groupInfo.fullGroupPreferences - when (feature) { - GroupFeature.TimedMessages -> p.timedMessages.on - GroupFeature.DirectMessages -> p.directMessages.on(groupInfo.membership) - GroupFeature.FullDelete -> p.fullDelete.on - GroupFeature.Reactions -> p.reactions.on - GroupFeature.Voice -> p.voice.on(groupInfo.membership) - GroupFeature.Files -> p.files.on(groupInfo.membership) - GroupFeature.SimplexLinks -> p.simplexLinks.on(groupInfo.membership) - GroupFeature.History -> p.history.on - } + chatInfo.groupInfo.groupFeatureEnabled(feature) } else { true } @@ -1233,9 +1323,11 @@ data class Chat( @Serializable data class ChatStats( val unreadCount: Int = 0, + val unreadMentions: Int = 0, // actual only via getChats() and getChat(.initial), otherwise, zero val reportsCount: Int = 0, val minUnreadItemId: Long = 0, + // actual only via getChats(), otherwise, false val unreadChat: Boolean = false ) @@ -1259,8 +1351,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contact.apiId override val ready get() = contact.ready override val chatDeleted get() = contact.chatDeleted - override val sendMsgEnabled get() = contact.sendMsgEnabled - override val ntfsEnabled get() = contact.ntfsEnabled override val incognito get() = contact.incognito override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL @@ -1278,15 +1368,13 @@ sealed class ChatInfo: SomeChat, NamedChat { } @Serializable @SerialName("group") - data class Group(val groupInfo: GroupInfo): ChatInfo() { + data class Group(val groupInfo: GroupInfo, val groupChatScope: GroupChatScopeInfo?): ChatInfo() { override val chatType get() = ChatType.Group override val localDisplayName get() = groupInfo.localDisplayName override val id get() = groupInfo.id override val apiId get() = groupInfo.apiId override val ready get() = groupInfo.ready override val chatDeleted get() = groupInfo.chatDeleted - override val sendMsgEnabled get() = groupInfo.sendMsgEnabled - override val ntfsEnabled get() = groupInfo.ntfsEnabled override val incognito get() = groupInfo.incognito override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature) override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL @@ -1298,7 +1386,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val localAlias get() = groupInfo.localAlias companion object { - val sampleData = Group(GroupInfo.sampleData) + val sampleData = Group(GroupInfo.sampleData, groupChatScope = null) } } @@ -1310,8 +1398,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = noteFolder.apiId override val ready get() = noteFolder.ready override val chatDeleted get() = noteFolder.chatDeleted - override val sendMsgEnabled get() = noteFolder.sendMsgEnabled - override val ntfsEnabled get() = noteFolder.ntfsEnabled override val incognito get() = noteFolder.incognito override fun featureEnabled(feature: ChatFeature) = noteFolder.featureEnabled(feature) override val timedMessagesTTL: Int? get() = noteFolder.timedMessagesTTL @@ -1335,8 +1421,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactRequest.apiId override val ready get() = contactRequest.ready override val chatDeleted get() = contactRequest.chatDeleted - override val sendMsgEnabled get() = contactRequest.sendMsgEnabled - override val ntfsEnabled get() = contactRequest.ntfsEnabled override val incognito get() = contactRequest.incognito override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL @@ -1360,8 +1444,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready override val chatDeleted get() = contactConnection.chatDeleted - override val sendMsgEnabled get() = contactConnection.sendMsgEnabled - override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL @@ -1379,20 +1461,20 @@ sealed class ChatInfo: SomeChat, NamedChat { } @Serializable @SerialName("invalidJSON") - class InvalidJSON(val json: String): ChatInfo() { + class InvalidJSON( + val json: String, + override val apiId: Long = -idGenerator.getAndIncrement(), + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = Clock.System.now() + ): ChatInfo() { override val chatType get() = ChatType.Direct override val localDisplayName get() = invalidChatName - override val id get() = "" - override val apiId get() = 0L + override val id get() = "?$apiId" override val ready get() = false override val chatDeleted get() = false - override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null - override val createdAt get() = Clock.System.now() - override val updatedAt get() = Clock.System.now() override val displayName get() = invalidChatName override val fullName get() = invalidChatName override val image get() = null @@ -1400,9 +1482,85 @@ sealed class ChatInfo: SomeChat, NamedChat { companion object { private val invalidChatName = generalGetString(MR.strings.invalid_chat) + private val idGenerator = AtomicLong(0) } } + val userCantSendReason: Pair? + get() { + when (this) { + is Direct -> { + // TODO [short links] this will have additional statuses for pending contact requests before they are accepted + if (contact.nextSendGrpInv) return null + if (!contact.active) return generalGetString(MR.strings.cant_send_message_contact_deleted) to null + if (!contact.sndReady) return generalGetString(MR.strings.cant_send_message_contact_not_ready) to null + if (contact.activeConn?.connectionStats?.ratchetSyncSendProhibited == true) return generalGetString(MR.strings.cant_send_message_contact_not_synchronized) to null + if (contact.activeConn?.connDisabled == true) return generalGetString(MR.strings.cant_send_message_contact_disabled) to null + return null + } + is Group -> { + if (groupInfo.membership.memberActive) { + when (groupChatScope) { + null -> { + if (groupInfo.membership.memberPending) { + return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc) + } + if (groupInfo.membership.memberRole == GroupMemberRole.Observer) { + return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + } + return null + } + is GroupChatScopeInfo.MemberSupport -> + if (groupChatScope.groupMember_ != null) { + if ( + groupChatScope.groupMember_.versionRange.maxVersion < GROUP_KNOCKING_VERSION + && !groupChatScope.groupMember_.memberPending + ) { + return generalGetString(MR.strings.cant_send_message_member_has_old_version) to null + } + return null + } else { + return null + } + } + } else { + return when (groupInfo.membership.memberStatus) { + GroupMemberStatus.MemRejected -> generalGetString(MR.strings.cant_send_message_rejected) to null + GroupMemberStatus.MemGroupDeleted -> generalGetString(MR.strings.cant_send_message_group_deleted) to null + GroupMemberStatus.MemRemoved -> generalGetString(MR.strings.cant_send_message_mem_removed) to null + GroupMemberStatus.MemLeft -> generalGetString(MR.strings.cant_send_message_you_left) to null + else -> generalGetString(MR.strings.cant_send_message_generic) to null + } + } + } + is Local -> + return null + is ContactRequest -> + return generalGetString(MR.strings.cant_send_message_generic) to null + is ContactConnection -> + return generalGetString(MR.strings.cant_send_message_generic) to null + is InvalidJSON -> + return generalGetString(MR.strings.cant_send_message_generic) to null + } + } + + val sendMsgEnabled get() = userCantSendReason == null + + fun groupChatScope(): GroupChatScope? = when (this) { + is Group -> groupChatScope?.toChatScope() + else -> null + } + + fun ntfsEnabled(ci: ChatItem): Boolean = + ntfsEnabled(ci.meta.userMention) + + fun ntfsEnabled(userMention: Boolean): Boolean = + when (chatSettings?.enableNtfs) { + All -> true + Mentions -> userMention + else -> false + } + val chatSettings get() = when(this) { is Direct -> contact.chatSettings @@ -1420,14 +1578,6 @@ sealed class ChatInfo: SomeChat, NamedChat { is InvalidJSON -> updatedAt } - val userCanSend: Boolean - get() = when (this) { - is ChatInfo.Direct -> true - is ChatInfo.Group -> groupInfo.membership.memberRole >= GroupMemberRole.Member - is ChatInfo.Local -> true - else -> false - } - val chatTags: List? get() = when (this) { is Direct -> contact.chatTags @@ -1435,12 +1585,22 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> null } + val nextNtfMode: MsgFilter? get() = this.chatSettings?.enableNtfs?.nextMode(mentions = this.hasMentions) + + val hasMentions: Boolean get() = this is Group + val contactCard: Boolean get() = when (this) { is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active else -> false } - } + + val groupInfo_: GroupInfo? + get() = when (this) { + is Group -> groupInfo + else -> null + } +} @Serializable sealed class NetworkStatus { @@ -1484,6 +1644,7 @@ data class Contact( val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, val chatTags: List, + val chatItemTTL: Long?, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { @@ -1493,15 +1654,7 @@ data class Contact( override val ready get() = activeConn?.connStatus == ConnStatus.Ready val sndReady get() = ready || activeConn?.connStatus == ConnStatus.SndReady val active get() = contactStatus == ContactStatus.Active - override val sendMsgEnabled get() = ( - sndReady - && active - && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false) - && !(activeConn?.connDisabled ?: true) - ) - || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser @@ -1567,7 +1720,8 @@ data class Contact( contactGrpInvSent = false, chatDeleted = false, uiThemes = null, - chatTags = emptyList() + chatTags = emptyList(), + chatItemTTL = null, ) } } @@ -1719,13 +1873,14 @@ data class GroupInfo ( val businessChat: BusinessChatInfo? = null, val fullGroupPreferences: FullGroupPreferences, val membership: GroupMember, - val hostConnCustomUserProfileId: Long? = null, val chatSettings: ChatSettings, override val createdAt: Instant, override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, + val membersRequireAttention: Int, val chatTags: List, + val chatItemTTL: Long?, override val localAlias: String, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group @@ -1733,8 +1888,6 @@ data class GroupInfo ( override val apiId get() = groupId override val ready get() = membership.memberActive override val chatDeleted get() = false - override val sendMsgEnabled get() = membership.memberActive - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -1752,7 +1905,7 @@ data class GroupInfo ( get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent val canDelete: Boolean - get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrent + get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrentOrPending val canAddMembers: Boolean get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive @@ -1760,6 +1913,21 @@ data class GroupInfo ( val canModerate: Boolean get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive + fun groupFeatureEnabled(feature: GroupFeature): Boolean { + val p = fullGroupPreferences + return when (feature) { + GroupFeature.TimedMessages -> p.timedMessages.on + GroupFeature.DirectMessages -> p.directMessages.on(membership) + GroupFeature.FullDelete -> p.fullDelete.on + GroupFeature.Reactions -> p.reactions.on + GroupFeature.Voice -> p.voice.on(membership) + GroupFeature.Files -> p.files.on(membership) + GroupFeature.SimplexLinks -> p.simplexLinks.on(membership) + GroupFeature.Reports -> p.reports.on + GroupFeature.History -> p.history.on + } + } + companion object { val sampleData = GroupInfo( groupId = 1, @@ -1767,14 +1935,15 @@ data class GroupInfo ( groupProfile = GroupProfile.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, - hostConnCustomUserProfileId = null, chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, + membersRequireAttention = 0, chatTags = emptyList(), - localAlias = "" + localAlias = "", + chatItemTTL = null ) } } @@ -1789,7 +1958,8 @@ data class GroupProfile ( val description: String? = null, override val image: String? = null, override val localAlias: String = "", - val groupPreferences: GroupPreferences? = null + val groupPreferences: GroupPreferences? = null, + val memberAdmission: GroupMemberAdmission? = null ): NamedChat { companion object { val sampleData = GroupProfile( @@ -1799,6 +1969,27 @@ data class GroupProfile ( } } +@Serializable +data class GroupMemberAdmission( + val review: MemberCriteria? = null, +) { + companion object { + val sampleData = GroupMemberAdmission( + review = null, + ) + } +} + +@Serializable +enum class MemberCriteria { + @SerialName("all") All; + + val text: String + get() = when(this) { + MemberCriteria.All -> generalGetString(MR.strings.member_criteria_all) + } +} + @Serializable data class BusinessChatInfo ( val chatType: BusinessChatType, @@ -1827,7 +2018,9 @@ data class GroupMember ( val memberProfile: LocalProfile, val memberContactId: Long? = null, val memberContactProfileId: Long, - var activeConn: Connection? = null + var activeConn: Connection? = null, + val supportChat: GroupSupportChat? = null, + val memberChatVRange: VersionRange ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -1865,12 +2058,28 @@ data class GroupMember ( name } + val localAliasAndFullName: String + get() { + val p = memberProfile + val fullName = p.displayName + (if (p.fullName == "" || p.fullName == p.displayName) "" else " / ${p.fullName}") + + val name = if (p.localAlias.isNotEmpty()) { + "${p.localAlias} ($fullName)" + } else { + fullName + } + return pastMember(name) + } + val memberActive: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemRejected -> false GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false GroupMemberStatus.MemGroupDeleted -> false GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false + GroupMemberStatus.MemPendingApproval -> true + GroupMemberStatus.MemPendingReview -> true GroupMemberStatus.MemIntroduced -> false GroupMemberStatus.MemIntroInvited -> false GroupMemberStatus.MemAccepted -> false @@ -1881,11 +2090,14 @@ data class GroupMember ( } val memberCurrent: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemRejected -> false GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false GroupMemberStatus.MemGroupDeleted -> false GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false + GroupMemberStatus.MemPendingApproval -> false + GroupMemberStatus.MemPendingReview -> false GroupMemberStatus.MemIntroduced -> true GroupMemberStatus.MemIntroInvited -> true GroupMemberStatus.MemAccepted -> true @@ -1895,6 +2107,15 @@ data class GroupMember ( GroupMemberStatus.MemCreator -> true } + val memberPending: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemPendingApproval -> true + GroupMemberStatus.MemPendingReview -> true + else -> false + } + + val memberCurrentOrPending: Boolean get() = + memberCurrent || memberPending + fun canBeRemoved(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft @@ -1909,10 +2130,12 @@ data class GroupMember ( fun canBlockForAll(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Admin - && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive + return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Moderator + && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive } + val versionRange: VersionRange = activeConn?.peerChatVRange ?: memberChatVRange + val memberIncognito = memberProfile.profileId != memberContactProfileId companion object { @@ -1930,11 +2153,20 @@ data class GroupMember ( memberProfile = LocalProfile.sampleData, memberContactId = 1, memberContactProfileId = 1L, - activeConn = Connection.sampleData + activeConn = Connection.sampleData, + memberChatVRange = VersionRange(minVersion = 1, maxVersion = 15) ) } } +@Serializable +class GroupSupportChat ( + val chatTs: Instant, + val unread: Int, + val memberAttention: Int, + val mentions: Int +) + @Serializable data class GroupMemberSettings(val showMessages: Boolean) {} @@ -1984,11 +2216,14 @@ enum class GroupMemberCategory { @Serializable enum class GroupMemberStatus { + @SerialName("rejected") MemRejected, @SerialName("removed") MemRemoved, @SerialName("left") MemLeft, @SerialName("deleted") MemGroupDeleted, @SerialName("unknown") MemUnknown, @SerialName("invited") MemInvited, + @SerialName("pending_approval") MemPendingApproval, + @SerialName("pending_review") MemPendingReview, @SerialName("introduced") MemIntroduced, @SerialName("intro-inv") MemIntroInvited, @SerialName("accepted") MemAccepted, @@ -1998,11 +2233,14 @@ enum class GroupMemberStatus { @SerialName("creator") MemCreator; val text: String get() = when (this) { + MemRejected -> generalGetString(MR.strings.group_member_status_rejected) MemRemoved -> generalGetString(MR.strings.group_member_status_removed) MemLeft -> generalGetString(MR.strings.group_member_status_left) MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) MemUnknown -> generalGetString(MR.strings.group_member_status_unknown) MemInvited -> generalGetString(MR.strings.group_member_status_invited) + MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval) + MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review) MemIntroduced -> generalGetString(MR.strings.group_member_status_introduced) MemIntroInvited -> generalGetString(MR.strings.group_member_status_intro_invitation) MemAccepted -> generalGetString(MR.strings.group_member_status_accepted) @@ -2013,11 +2251,14 @@ enum class GroupMemberStatus { } val shortText: String get() = when (this) { + MemRejected -> generalGetString(MR.strings.group_member_status_rejected) MemRemoved -> generalGetString(MR.strings.group_member_status_removed) MemLeft -> generalGetString(MR.strings.group_member_status_left) MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) MemUnknown -> generalGetString(MR.strings.group_member_status_unknown_short) MemInvited -> generalGetString(MR.strings.group_member_status_invited) + MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval_short) + MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review_short) MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting) MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting) MemAccepted -> generalGetString(MR.strings.group_member_status_connecting) @@ -2072,8 +2313,6 @@ class NoteFolder( override val apiId get() = noteFolderId override val chatDeleted get() = false override val ready get() = true - override val sendMsgEnabled get() = true - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice override val timedMessagesTTL: Int? get() = null @@ -2109,8 +2348,6 @@ class UserContactRequest ( override val apiId get() = contactRequestId override val chatDeleted get() = false override val ready get() = true - override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2139,7 +2376,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 @@ -2149,8 +2386,6 @@ class PendingContactConnection( override val apiId get() = pccConnId override val chatDeleted get() = false override val ready get() = false - override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = customUserProfileId != null override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2251,6 +2486,30 @@ data class MemberReaction( val reactionTs: Instant ) +@Serializable +data class CIMentionMember( + val groupMemberId: Long, + val displayName: String, + val localAlias: String?, + val memberRole: GroupMemberRole +) + +@Serializable +data class CIMention( + val memberId: String, + val memberRef: CIMentionMember? +) { + constructor(groupMember: GroupMember): this( + groupMember.memberId, + CIMentionMember( + groupMember.groupMemberId, + groupMember.memberProfile.displayName, + groupMember.memberProfile.localAlias, + groupMember.memberRole + ) + ) +} + @Serializable class CIReaction( val chatDir: CIDirection, @@ -2265,6 +2524,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, val file: CIFile? = null @@ -2343,11 +2603,16 @@ data class ChatItem ( is CIContent.RcvGroupFeature, is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { - is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null + is RcvGroupEvent.UserRole, + is RcvGroupEvent.UserDeleted, + is RcvGroupEvent.GroupDeleted, + is RcvGroupEvent.MemberCreatedContact, + is RcvGroupEvent.NewMemberPendingReview -> + null else -> CIMergeCategory.RcvGroupEvent } is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { - is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null else -> CIMergeCategory.SndGroupEvent } else -> { @@ -2362,14 +2627,14 @@ data class ChatItem ( fun memberToModerate(chatInfo: ChatInfo): Pair? { return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { val m = chatInfo.groupInfo.membership - if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) { + if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) { chatInfo.groupInfo to chatDir.groupMember } else { null } } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupSnd) { val m = chatInfo.groupInfo.membership - if (m.memberRole >= GroupMemberRole.Admin) { + if (m.memberRole >= GroupMemberRole.Moderator) { chatInfo.groupInfo to null } else { null @@ -2418,6 +2683,8 @@ data class ChatItem ( is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberConnected -> false + is RcvGroupEvent.MemberAccepted -> false + is RcvGroupEvent.UserAccepted -> false is RcvGroupEvent.MemberLeft -> false is RcvGroupEvent.MemberRole -> false is RcvGroupEvent.MemberBlocked -> false @@ -2429,6 +2696,7 @@ data class ChatItem ( is RcvGroupEvent.InvitedViaGroupLink -> false is RcvGroupEvent.MemberCreatedContact -> false is RcvGroupEvent.MemberProfileUpdated -> false + is RcvGroupEvent.NewMemberPendingReview -> true } is CIContent.SndGroupEventContent -> false is CIContent.RcvConnEventContent -> false @@ -2564,7 +2832,8 @@ data class ChatItem ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), quotedItem = null, @@ -2588,7 +2857,8 @@ data class ChatItem ( itemTimed = null, itemLive = true, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.SndMsgContent(MsgContent.MCText("")), quotedItem = null, @@ -2608,12 +2878,13 @@ data class ChatItem ( } } -fun MutableState>.add(index: Int, elem: Chat) { - value = SnapshotStateList().apply { addAll(value); add(index, elem) } +sealed class SecondaryContextFilter { + class GroupChatScopeContext(val groupScopeInfo: GroupChatScopeInfo): SecondaryContextFilter() + class MsgContentTagContext(val contentTag: MsgContentTag): SecondaryContextFilter() } -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(index: Int, elem: Chat) { + value = SnapshotStateList().apply { addAll(value); add(index, elem) } } fun MutableState>.add(elem: Chat) { @@ -2623,11 +2894,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) } } @@ -2640,6 +2906,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>() @@ -2654,8 +2921,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) } } @@ -2667,17 +2934,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) } } @@ -2686,11 +2942,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() @@ -2745,6 +3002,7 @@ data class CIMeta ( val itemEdited: Boolean, val itemTimed: CITimed?, val itemLive: Boolean?, + val userMention: Boolean, val deletable: Boolean, val editable: Boolean ) { @@ -2783,7 +3041,8 @@ data class CIMeta ( itemTimed = itemTimed, itemLive = itemLive, deletable = deletable, - editable = editable + editable = editable, + userMention = false, ) fun invalidJSON(): CIMeta = @@ -2802,7 +3061,8 @@ data class CIMeta ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false ) } } @@ -2881,6 +3141,19 @@ sealed class CIStatus { @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus() + // as in corresponds to SENT response from agent + fun isSent(): Boolean = when(this) { + is SndNew -> false + is SndSent -> true + is SndRcvd -> false + is SndErrorAuth -> true + is CISSndError -> true + is SndWarning -> true + is RcvNew -> false + is RcvRead -> false + is Invalid -> false + } + fun statusIcon( primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary, @@ -2920,6 +3193,13 @@ sealed class CIStatus { } } +fun shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus): Boolean = + when { + oldStatus is CIStatus.SndRcvd && newStatus !is CIStatus.SndRcvd -> true + oldStatus.isSent() && newStatus is CIStatus.SndNew -> true + else -> false + } + @Serializable sealed class SndError { @Serializable @SerialName("auth") class Auth: SndError() @@ -3035,6 +3315,13 @@ sealed class CIForwardedFrom { is Group -> chatName } + val chatTypeApiIdMsgId: Triple? + get() = when (this) { + Unknown -> null + is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null + is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null + } + fun text(chatType: ChatType): String = if (chatType == ChatType.Local) { if (chatName.isEmpty()) { @@ -3170,6 +3457,7 @@ sealed class CIContent: ItemContent { when (role) { GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners) GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins) + GroupMemberRole.Moderator -> generalGetString(MR.strings.feature_roles_moderators) else -> generalGetString(MR.strings.feature_roles_all_members) } @@ -3247,18 +3535,20 @@ sealed class MsgReaction { MREmojiChar.Heart -> "❤️" else -> emoji.value } - is Unknown -> "" + is Unknown -> "?" } companion object { - val values: List get() = MREmojiChar.values().map(::Emoji) - val old: List get() = listOf( + val values: List get() = MREmojiChar.entries.map(::Emoji) + val supported: List get() = listOf( MREmojiChar.ThumbsUp, MREmojiChar.ThumbsDown, MREmojiChar.Smile, + MREmojiChar.Laugh, MREmojiChar.Sad, MREmojiChar.Heart, - MREmojiChar.Launch + MREmojiChar.Launch, + MREmojiChar.Check ).map(::Emoji) } } @@ -3277,8 +3567,13 @@ object MsgReactionSerializer : KSerializer { return if (json is JsonObject && "type" in json) { when(val t = json["type"]?.jsonPrimitive?.content ?: "") { "emoji" -> { - val emoji = Json.decodeFromString(json["emoji"].toString()) - if (emoji == null) MsgReaction.Unknown(t, json) else MsgReaction.Emoji(emoji) + val msgReaction = try { + val emoji = Json.decodeFromString(json["emoji"].toString()) + MsgReaction.Emoji(emoji) + } catch (e: Throwable) { + MsgReaction.Unknown(t, json) + } + msgReaction } else -> MsgReaction.Unknown(t, json) } @@ -3806,7 +4101,7 @@ enum class MsgContentTag { class FormattedText(val text: String, val format: Format? = null) { // TODO make it dependent on simplexLinkMode preference fun link(mode: SimplexLinkMode): String? = when (format) { - is Format.Uri -> text + is Format.Uri -> if (text.startsWith("http://", ignoreCase = true) || text.startsWith("https://", ignoreCase = true)) text else "https://$text" is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri is Format.Email -> "mailto:$text" is Format.Phone -> "tel:$text" @@ -3819,6 +4114,10 @@ class FormattedText(val text: String, val format: Format? = null) { fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List): String = "${linkType.description} (${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" + + companion object { + fun plain(text: String): List = if (text.isEmpty()) emptyList() else listOf(FormattedText(text)) + } } @Serializable @@ -3831,6 +4130,7 @@ sealed class Format { @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() + @Serializable @SerialName("mention") class Mention(val memberName: String): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() @@ -3843,6 +4143,7 @@ sealed class Format { is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle is SimplexLink -> linkStyle + is Mention -> SpanStyle(fontWeight = FontWeight.Medium) is Email -> linkStyle is Phone -> linkStyle } @@ -3858,12 +4159,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 }) } @@ -4035,6 +4338,8 @@ sealed class RcvDirectEvent() { sealed class RcvGroupEvent() { @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() @Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() + @Serializable @SerialName("userAccepted") class UserAccepted(): RcvGroupEvent() @Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent() @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent() @Serializable @SerialName("memberBlocked") class MemberBlocked(val groupMemberId: Long, val profile: Profile, val blocked: Boolean): RcvGroupEvent() @@ -4046,10 +4351,13 @@ sealed class RcvGroupEvent() { @Serializable @SerialName("invitedViaGroupLink") class InvitedViaGroupLink(): RcvGroupEvent() @Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent() @Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent() + @Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent() val text: String get() = when (this) { is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName) is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected) + is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName) + is UserAccepted -> generalGetString(MR.strings.rcv_group_event_user_accepted) is MemberLeft -> generalGetString(MR.strings.rcv_group_event_member_left) is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text) is MemberBlocked -> if (blocked) { @@ -4065,6 +4373,7 @@ sealed class RcvGroupEvent() { is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link) is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact) is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile) + is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review) } private fun profileUpdatedText(from: Profile, to: Profile): String = @@ -4089,6 +4398,8 @@ sealed class SndGroupEvent() { @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent() @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() + @Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent() val text: String get() = when (this) { is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text) @@ -4101,6 +4412,8 @@ sealed class SndGroupEvent() { is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName) is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left) is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated) + is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted) + is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review) } } @@ -4186,32 +4499,49 @@ enum class SwitchPhase { @SerialName("completed") Completed } -sealed class ChatItemTTL: Comparable { +sealed class ChatItemTTL: Comparable { object Day: ChatItemTTL() object Week: ChatItemTTL() object Month: ChatItemTTL() + object Year: ChatItemTTL() data class Seconds(val secs: Long): ChatItemTTL() object None: ChatItemTTL() - override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE) + override fun compareTo(other: ChatItemTTL): Int = + (seconds.takeIf { it != 0L } ?: Long.MAX_VALUE) + .compareTo(other.seconds.takeIf { it != 0L } ?: Long.MAX_VALUE) - val seconds: Long? + val seconds: Long get() = when (this) { - is None -> null + is None -> 0 is Day -> 86400L is Week -> 7 * 86400L is Month -> 30 * 86400L + is Year -> 365 * 86400L is Seconds -> secs } + val text: String + get() = when(this) { + is None -> generalGetString(MR.strings.chat_item_ttl_none) + is Day -> generalGetString(MR.strings.chat_item_ttl_day) + is Week -> generalGetString(MR.strings.chat_item_ttl_week) + is Month -> generalGetString(MR.strings.chat_item_ttl_month) + is Year -> generalGetString(MR.strings.chat_item_ttl_year) + is Seconds -> String.format(generalGetString(MR.strings.chat_item_ttl_seconds), secs) + } + + val neverExpires: Boolean get() = this is None + companion object { - fun fromSeconds(seconds: Long?): ChatItemTTL = + fun fromSeconds(seconds: Long): ChatItemTTL = when (seconds) { - null -> None + 0L -> None 86400L -> Day 7 * 86400L -> Week 30 * 86400L -> Month + 365 * 86400L -> Year else -> Seconds(seconds) } } 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 f891d206a3..0f65783152 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,9 +19,9 @@ 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.SMPErrorType.BLOCKED +import chat.simplex.common.model.MsgContent.MCUnknown +import chat.simplex.common.model.SMPProxyFallback.AllowProtected +import chat.simplex.common.model.SMPProxyMode.Always import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -36,6 +38,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -44,6 +47,9 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* import kotlinx.serialization.builtins.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.util.Date @@ -52,6 +58,9 @@ typealias ChatCtrl = Long // version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) val CREATE_MEMBER_CONTACT_VERSION = 2 +// support group knocking (MsgScope) +val GROUP_KNOCKING_VERSION = 15 + enum class CallOnLockScreen { DISABLE, SHOW, @@ -85,18 +94,7 @@ class AppPreferences { val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true) - private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name) - val callOnLockScreen: SharedPreference = SharedPreference( - get = fun(): CallOnLockScreen { - val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default - return try { - CallOnLockScreen.valueOf(value) - } catch (e: Throwable) { - CallOnLockScreen.default - } - }, - set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) } - ) + val callOnLockScreen: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default) val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false) val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.default) { LAMode.values().firstOrNull { it.name == this } } val laLockDelay = mkIntPreference(SHARED_PREFS_LA_LOCK_DELAY, 30) @@ -106,20 +104,10 @@ class AppPreferences { val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } } - private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name) - val simplexLinkMode: SharedPreference = SharedPreference( - get = fun(): SimplexLinkMode { - val value = _simplexLinkMode.get() ?: return SimplexLinkMode.default - return try { - SimplexLinkMode.valueOf(value) - } catch (e: Throwable) { - SimplexLinkMode.default - } - }, - set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) } - ) + val simplexLinkMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default) 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) @@ -156,22 +144,12 @@ class AppPreferences { }, set = fun(proxy: NetworkProxy) { _networkProxy.set(json.encodeToString(proxy)) } ) - private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) - val networkSessionMode: SharedPreference = SharedPreference( - get = fun(): TransportSessionMode { - val value = _networkSessionMode.get() ?: return TransportSessionMode.default - return try { - TransportSessionMode.valueOf(value) - } catch (e: Throwable) { - TransportSessionMode.default - } - }, - set = fun(mode: TransportSessionMode) { _networkSessionMode.set(mode.name) } - ) - val networkSMPProxyMode = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_MODE, NetCfg.defaults.smpProxyMode.name) - val networkSMPProxyFallback = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, NetCfg.defaults.smpProxyFallback.name) - val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) + val networkSessionMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default) + val networkSMPProxyMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SMP_PROXY_MODE, SMPProxyMode.default) + val networkSMPProxyFallback: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, SMPProxyFallback.default) + val networkHostMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.default) val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) + val networkSMPWebPortServers: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SMP_WEB_PORT_SERVERS, SMPWebPortServers.default) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout) val networkTCPTimeoutPerKb = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB, NetCfg.defaults.tcpTimeoutPerKb, NetCfg.proxyDefaults.tcpTimeoutPerKb) @@ -188,6 +166,7 @@ class AppPreferences { val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false) val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) + val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true) val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) val appUpdateChannel = mkEnumPreference(SHARED_PREFS_APP_UPDATE_CHANNEL, AppUpdatesChannel.DISABLED) { AppUpdatesChannel.entries.firstOrNull { it.name == this } } val appSkippedUpdate = mkStrPreference(SHARED_PREFS_APP_SKIPPED_UPDATE, "") @@ -270,6 +249,7 @@ class AppPreferences { liveMessageAlertShown to false, showHiddenProfilesNotice to true, showMuteProfileAlert to true, + showReportsInSupportChatAlert to true, showDeleteConversationNotice to true, showDeleteContactNotice to true, ) @@ -326,7 +306,19 @@ class AppPreferences { set = fun(value) = settings.putString(prefName, value.toString()) ) - // LALAL + private inline fun > mkSafeEnumPreference(key: String, default: T): SharedPreference = SharedPreference( + get = { + val value = settings.getString(key, "") + if (value == "") return@SharedPreference default + try { + enumValueOf(value) + } catch (e: IllegalArgumentException) { + default + } + }, + set = { value -> settings.putString(key, value.name) } + ) + private fun mkDatePreference(prefName: String, default: Instant?): SharedPreference = SharedPreference( get = { @@ -378,6 +370,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" @@ -410,6 +403,7 @@ class AppPreferences { private const val SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK = "NetworkSMPProxyFallback" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" + private const val SHARED_PREFS_NETWORK_SMP_WEB_PORT_SERVERS = "NetworkSMPWebPortServers" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -426,6 +420,7 @@ class AppPreferences { private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown" private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown" private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert" + private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert" private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase" private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase" @@ -479,7 +474,7 @@ object ChatController { var ctrl: ChatCtrl? = -1 val appPrefs: AppPreferences by lazy { AppPreferences() } - val messagesChannel: Channel = Channel() + val messagesChannel: Channel = Channel() val chatModel = ChatModel private var receiverStarted = false @@ -492,8 +487,7 @@ object ChatController { val userId = currentUserId("getAgentSubsTotal") val r = sendCmd(rh, CC.GetAgentSubsTotal(userId), log = false) - - if (r is CR.AgentSubsTotal) return r.subsTotal to r.hasSession + if (r is API.Result && r.res is CR.AgentSubsTotal) return r.res.subsTotal to r.res.hasSession Log.e(TAG, "getAgentSubsTotal bad response: ${r.responseType} ${r.details}") return null } @@ -502,8 +496,7 @@ object ChatController { val userId = currentUserId("getAgentServersSummary") val r = sendCmd(rh, CC.GetAgentServersSummary(userId), log = false) - - if (r is CR.AgentServersSummary) return r.serversSummary + if (r is API.Result && r.res is CR.AgentServersSummary) return r.res.serversSummary Log.e(TAG, "getAgentServersSummary bad response: ${r.responseType} ${r.details}") return null } @@ -541,9 +534,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") } @@ -624,9 +617,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 @@ -655,11 +648,11 @@ object ChatController { messagesChannel.trySend(msg) } if (finishedWithoutTimeout == null) { - Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + Log.e(TAG, "Timeout reached while processing received message: " + msg.responseType) if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.possible_slow_function_title), - text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.responseType + "\n" + Exception().stackTraceToString()), shareText = true ) } @@ -675,7 +668,7 @@ object ChatController { } } - suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, log: Boolean = true): CR { + suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, log: Boolean = true): API { val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { @@ -684,37 +677,36 @@ object ChatController { chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") } - val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) + val rStr = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) // coroutine was cancelled already, no need to process response (helps with apiListMembers - very heavy query in large groups) interruptIfCancelled() - val r = APIResponse.decodeStr(json) + val r = json.decodeFromString(rStr) if (log) { - Log.d(TAG, "sendCmd response type ${r.resp.responseType}") - if (r.resp is CR.Response || r.resp is CR.Invalid) { - Log.d(TAG, "sendCmd response json $json") + Log.d(TAG, "sendCmd response type ${r.responseType}") + if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) { + Log.d(TAG, "sendCmd response json $rStr") } - chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) } - r.resp + r } } - fun recvMsg(ctrl: ChatCtrl): APIResponse? { - val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) - return if (json == "") { + fun recvMsg(ctrl: ChatCtrl): API? { + val rStr = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) + return if (rStr == "") { null } else { - val apiResp = APIResponse.decodeStr(json) - val r = apiResp.resp + val r = json.decodeFromString(rStr) Log.d(TAG, "chatRecvMsg: ${r.responseType}") - if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json") - apiResp + if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr") + r } } suspend fun apiGetActiveUser(rh: Long?, ctrl: ChatCtrl? = null): User? { val r = sendCmd(rh, CC.ShowActiveUser(), ctrl) - if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") if (rh == null) { chatModel.localUserCreated.value = false @@ -724,14 +716,15 @@ object ChatController { suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? { val r = sendCmd(rh, CC.CreateActiveUser(p, pastTimestamp = pastTimestamp), ctrl) - if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) - else if ( - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.UserExists + if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) + val e = (r as? API.Error)?.err + if ( + e is ChatError.ChatErrorStore && e.storeError is StoreError.DuplicateName || + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UserExists ) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) } else if ( - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidDisplayName + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.InvalidDisplayName ) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_invalid_title), generalGetString(MR.strings.failed_to_create_user_invalid_desc)) } else { @@ -743,8 +736,8 @@ object ChatController { suspend fun listUsers(rh: Long?): List { val r = sendCmd(rh, CC.ListUsers()) - if (r is CR.UsersList) { - val users = if (rh == null) r.users else r.users.map { it.copy(user = it.user.copy(remoteHostId = rh)) } + if (r is API.Result && r.res is CR.UsersList) { + val users = if (rh == null) r.res.users else r.res.users.map { it.copy(user = it.user.copy(remoteHostId = rh)) } return users.sortedBy { it.user.chatViewName } } Log.d(TAG, "listUsers: ${r.responseType} ${r.details}") @@ -753,26 +746,26 @@ object ChatController { suspend fun apiSetActiveUser(rh: Long?, userId: Long, viewPwd: String?): User { val r = sendCmd(rh, CC.ApiSetActiveUser(userId, viewPwd)) - if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } suspend fun apiSetAllContactReceipts(rh: Long?, enable: Boolean) { val r = sendCmd(rh, CC.SetAllContactReceipts(enable)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}") } suspend fun apiSetUserContactReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { val r = sendCmd(u.remoteHostId, CC.ApiSetUserContactReceipts(u.userId, userMsgReceiptSettings)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}") } suspend fun apiSetUserGroupReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { val r = sendCmd(u.remoteHostId, CC.ApiSetUserGroupReceipts(u.userId, userMsgReceiptSettings)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}") } @@ -790,20 +783,20 @@ object ChatController { private suspend fun setUserPrivacy(rh: Long?, cmd: CC): User { val r = sendCmd(rh, cmd) - if (r is CR.UserPrivacy) return r.updatedUser.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.UserPrivacy) return r.res.updatedUser.updateRemoteHostId(rh) else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } suspend fun apiDeleteUser(u: User, delSMPQueues: Boolean, viewPwd: String?) { val r = sendCmd(u.remoteHostId, CC.ApiDeleteUser(u.userId, delSMPQueues, viewPwd)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}") throw Exception("failed to delete the user ${r.responseType} ${r.details}") } suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(null, CC.StartChat(mainApp = true), ctrl) - when (r) { + when (r.result) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false else -> throw Exception("failed starting chat: ${r.responseType} ${r.details}") @@ -812,7 +805,7 @@ object ChatController { private suspend fun apiCheckChatRunning(): Boolean { val r = sendCmd(null, CC.CheckChatRunning()) - when (r) { + when (r.result) { is CR.ChatRunning -> return true is CR.ChatStopped -> return false else -> throw Exception("failed check chat running: ${r.responseType} ${r.details}") @@ -821,15 +814,13 @@ object ChatController { suspend fun apiStopChat(): Boolean { val r = sendCmd(null, CC.ApiStopChat()) - when (r) { - is CR.ChatStopped -> return true - else -> throw Exception("failed stopping chat: ${r.responseType} ${r.details}") - } + if (r.result is CR.ChatStopped) return true + throw Exception("failed stopping chat: ${r.responseType} ${r.details}") } suspend fun apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, remoteHostsFolder: String, ctrl: ChatCtrl? = null) { val r = sendCmd(null, CC.ApiSetAppFilePaths(filesFolder, tempFolder, assetsFolder, remoteHostsFolder), ctrl) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set app file paths: ${r.responseType} ${r.details}") } @@ -837,52 +828,52 @@ object ChatController { suspend fun apiSaveAppSettings(settings: AppSettings) { val r = sendCmd(null, CC.ApiSaveSettings(settings)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set app settings: ${r.responseType} ${r.details}") } suspend fun apiGetAppSettings(settings: AppSettings): AppSettings { val r = sendCmd(null, CC.ApiGetSettings(settings)) - if (r is CR.AppSettingsR) return r.appSettings + if (r is API.Result && r.res is CR.AppSettingsR) return r.res.appSettings throw Exception("failed to get app settings: ${r.responseType} ${r.details}") } suspend fun apiExportArchive(config: ArchiveConfig): List { val r = sendCmd(null, CC.ApiExportArchive(config)) - if (r is CR.ArchiveExported) return r.archiveErrors + if (r is API.Result && r.res is CR.ArchiveExported) return r.res.archiveErrors throw Exception("failed to export archive: ${r.responseType} ${r.details}") } suspend fun apiImportArchive(config: ArchiveConfig): List { val r = sendCmd(null, CC.ApiImportArchive(config)) - if (r is CR.ArchiveImported) return r.archiveErrors + if (r is API.Result && r.res is CR.ArchiveImported) return r.res.archiveErrors throw Exception("failed to import archive: ${r.responseType} ${r.details}") } suspend fun apiDeleteStorage() { val r = sendCmd(null, CC.ApiDeleteStorage()) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to delete storage: ${r.responseType} ${r.details}") } - suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? { + suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): ChatError? { val r = sendCmd(null, CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) - if (r is CR.CmdOk) return null - else if (r is CR.ChatCmdError) return r + if (r.result is CR.CmdOk) return null + else if (r is API.Error) return r.err throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): CR.ChatCmdError? { + suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): ChatError? { val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl) - if (r is CR.CmdOk) return null - else if (r is CR.ChatCmdError) return r + if (r.result is CR.CmdOk) return null + else if (r is API.Error) return r.err throw Exception("failed to test storage encryption: ${r.responseType} ${r.details}") } suspend fun apiGetChats(rh: Long?): List { val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() } val r = sendCmd(rh, CC.ApiGetChats(userId)) - if (r is CR.ApiChats) return if (rh == null) r.chats else r.chats.map { it.copy(remoteHostId = rh) } + if (r is API.Result && r.res is CR.ApiChats) return if (rh == null) r.res.chats else r.res.chats.map { it.copy(remoteHostId = rh) } Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chats_title), generalGetString(MR.strings.contact_developers)) return emptyList() @@ -891,18 +882,18 @@ object ChatController { private suspend fun apiGetChatTags(rh: Long?): List?{ val userId = currentUserId("apiGetChatTags") val r = sendCmd(rh, CC.ApiGetChatTags(userId)) - - if (r is CR.ChatTags) return r.userTags + if (r is API.Result && r.res is CR.ChatTags) return r.res.userTags Log.e(TAG, "apiGetChatTags bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_chat_tags), "${r.responseType}: ${r.details}") return null } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { - val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, pagination, search)) - if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, scope, contentTag, pagination, search)) + if (r is API.Result && r.res is CR.ApiChat) return if (rh == null) r.res.chat to r.res.navInfo else r.res.chat.copy(remoteHostId = rh) to r.res.navInfo Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") - if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) { + val e = (r as? API.Error)?.err + if (pagination is ChatPagination.Around && e is ChatError.ChatErrorStore && e.storeError is StoreError.ChatItemNotFound) { showQuotedItemDoesNotExistAlert() } else { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) @@ -912,7 +903,7 @@ object ChatController { suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List? { val r = sendCmd(rh, CC.ApiCreateChatTag(tag)) - if (r is CR.ChatTags) return r.userTags + if (r is API.Result && r.res is CR.ChatTags) return r.res.userTags Log.e(TAG, "apiCreateChatTag bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_creating_chat_tags), "${r.responseType}: ${r.details}") return null @@ -920,7 +911,7 @@ object ChatController { suspend fun apiSetChatTags(rh: Long?, type: ChatType, id: Long, tagIds: List): Pair, List>? { val r = sendCmd(rh, CC.ApiSetChatTags(type, id, tagIds)) - if (r is CR.TagsUpdated) return r.userTags to r.chatTags + if (r is API.Result && r.res is CR.TagsUpdated) return r.res.userTags to r.res.chatTags Log.e(TAG, "apiSetChatTags bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_updating_chat_tags), "${r.responseType}: ${r.details}") return null @@ -932,16 +923,16 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) } private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): List? { val r = sendCmd(rh, cmd) return when { - r is CR.NewChatItems -> r.chatItems - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg && cmd is CC.ApiSendMessages -> { + r is API.Result && r.res is CR.NewChatItems -> r.res.chatItems + r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.LargeMsg && cmd is CC.ApiSendMessages -> { val mc = cmd.composedMessages.last().msgContent AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), @@ -953,7 +944,7 @@ object ChatController { ) null } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg && cmd is CC.ApiForwardChatItems -> { + r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.LargeMsg && cmd is CC.ApiForwardChatItems -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), generalGetString(MR.strings.maximum_message_size_reached_forwarding) @@ -968,60 +959,48 @@ object ChatController { } } } - suspend fun apiCreateChatItems(rh: Long?, noteFolderId: Long, composedMessages: List): List? { + + suspend fun apiCreateChatItems(rh: Long?, noteFolderId: Long, composedMessages: List): List? { val cmd = CC.ApiCreateChatItems(noteFolderId, composedMessages) val r = sendCmd(rh, cmd) - return when (r) { - is CR.NewChatItems -> r.chatItems - else -> { - apiErrorAlert("apiCreateChatItems", generalGetString(MR.strings.error_creating_message), r) - null - } - } + if (r is API.Result && r.res is CR.NewChatItems) return r.res.chatItems + apiErrorAlert("apiCreateChatItems", generalGetString(MR.strings.error_creating_message), r) + return null } suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List? { val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText)) - return when (r) { - is CR.NewChatItems -> r.chatItems - else -> { - apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) - null - } - } + if (r is API.Result && r.res is CR.NewChatItems) return r.res.chatItems + apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) + return null } - suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { - return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { - is CR.ApiChatItemInfo -> r.chatItemInfo - else -> { - apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) - null - } - } + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): ChatItemInfo? { + val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, scope, itemId)) + if (r is API.Result && r.res is CR.ApiChatItemInfo) return r.res.chatItemInfo + apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) + return null } - suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemIds: List, ttl: Int?): List? { - val cmd = CC.ApiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl) + suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { + val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl) return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } - suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, chatItemIds: List): CR.ForwardPlan? { - return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds))) { - is CR.ForwardPlan -> r - else -> { - apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r) - null - } - } + suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, chatItemIds: List): CR.ForwardPlan? { + val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, fromScope, chatItemIds)) + if (r is API.Result && r.res is CR.ForwardPlan) return r.res + apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r) + return null } - suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { - val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, scope, itemId, updatedMessage, live)) when { - r is CR.ChatItemUpdated -> return r.chatItem - r is CR.ChatItemNotChanged -> return r.chatItem - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> { + r is API.Result && r.res is CR.ChatItemUpdated -> return r.res.chatItem + r is API.Result && r.res is CR.ChatItemNotChanged -> return r.res.chatItem + r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.LargeMsg -> { + val mc = updatedMessage.msgContent AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), if (mc is MsgContent.MCImage || mc is MsgContent.MCVideo || mc is MsgContent.MCLink) { @@ -1038,9 +1017,9 @@ object ChatController { return null } - suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { - val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, itemId, add, reaction)) - if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem + suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { + val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, scope, itemId, add, reaction)) + if (r is API.Result && r.res is CR.ChatItemReaction) return r.res.reaction.chatReaction.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } @@ -1048,171 +1027,156 @@ object ChatController { suspend fun apiGetReactionMembers(rh: Long?, groupId: Long, itemId: Long, reaction: MsgReaction): List? { val userId = currentUserId("apiGetReactionMembers") val r = sendCmd(rh, CC.ApiGetReactionMembers(userId, groupId, itemId, reaction)) - if (r is CR.ReactionMembers) return r.memberReactions + if (r is API.Result && r.res is CR.ReactionMembers) return r.res.memberReactions Log.e(TAG, "apiGetReactionMembers bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, itemIds: List, mode: CIDeleteMode): List? { - val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, itemIds, mode)) - if (r is CR.ChatItemsDeleted) return r.chatItemDeletions + suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List, mode: CIDeleteMode): List? { + val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode)) + if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null } suspend fun apiDeleteMemberChatItems(rh: Long?, groupId: Long, itemIds: List): List? { val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, itemIds)) - if (r is CR.ChatItemsDeleted) return r.chatItemDeletions + if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") return null } + suspend fun apiArchiveReceivedReports(rh: Long?, groupId: Long): CR.GroupChatItemsDeleted? { + val r = sendCmd(rh, CC.ApiArchiveReceivedReports(groupId)) + if (r is API.Result && r.res is CR.GroupChatItemsDeleted) return r.res + Log.e(TAG, "apiArchiveReceivedReports bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiDeleteReceivedReports(rh: Long?, groupId: Long, itemIds: List, mode: CIDeleteMode): List? { + val r = sendCmd(rh, CC.ApiDeleteReceivedReports(groupId, itemIds, mode)) + if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions + Log.e(TAG, "apiDeleteReceivedReports bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) - return when (r) { - is CR.ServerTestResult -> r.testFailure - else -> { - Log.e(TAG, "testProtoServer bad response: ${r.responseType} ${r.details}") - throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") - } - } + if (r is API.Result && r.res is CR.ServerTestResult) return r.res.testFailure + Log.e(TAG, "testProtoServer bad response: ${r.responseType} ${r.details}") + throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") } suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiGetServerOperators()) - - return when (r) { - is CR.ServerOperatorConditions -> r.conditions - else -> { - Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions + Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}") + return null } suspend fun setServerOperators(rh: Long?, operators: List): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiSetServerOperators(operators)) - return when (r) { - is CR.ServerOperatorConditions -> r.conditions - else -> { - Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions + Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}") + return null } suspend fun getUserServers(rh: Long?): List? { val userId = currentUserId("getUserServers") val r = sendCmd(rh, CC.ApiGetUserServers(userId)) - return when (r) { - is CR.UserServers -> r.userServers - else -> { - Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.UserServers) return r.res.userServers + Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}") + return null } suspend fun setUserServers(rh: Long?, userServers: List): Boolean { val userId = currentUserId("setUserServers") val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers)) - return when (r) { - is CR.CmdOk -> true - else -> { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.failed_to_save_servers), - "${r.responseType}: ${r.details}" - ) - Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}") - false - } - } + if (r.result is CR.CmdOk) return true + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.failed_to_save_servers), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}") + return false } suspend fun validateServers(rh: Long?, userServers: List): List? { val userId = currentUserId("validateServers") val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) - return when (r) { - is CR.UserServersValidation -> r.serverErrors - else -> { - Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.UserServersValidation) return r.res.serverErrors + Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") + return null } suspend fun getUsageConditions(rh: Long?): Triple? { val r = sendCmd(rh, CC.ApiGetUsageConditions()) - return when (r) { - is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions) - else -> { - Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.UsageConditions) return Triple(r.res.usageConditions, r.res.conditionsText, r.res.acceptedConditions) + Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}") + return null } suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean { val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId)) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}") - false - } - } + if (r.result is CR.CmdOk) return true + Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}") + return false } suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds)) - return when (r) { - is CR.ServerOperatorConditions -> r.conditions - else -> { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.error_accepting_operator_conditions), - "${r.responseType}: ${r.details}" - ) - Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_accepting_operator_conditions), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}") + return null } suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) - if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) + if (r is API.Result && r.res is CR.ChatItemTTL) { + return if (r.res.chatItemTTL != null) { + ChatItemTTL.fromSeconds(r.res.chatItemTTL) + } else { + ChatItemTTL.None + } + } throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } suspend fun setChatItemTTL(rh: Long?, chatItemTTL: ChatItemTTL) { val userId = currentUserId("setChatItemTTL") val r = sendCmd(rh, CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } + suspend fun setChatTTL(rh: Long?, chatType: ChatType, id: Long, chatItemTTL: ChatItemTTL?) { + val userId = currentUserId("setChatTTL") + val r = sendCmd(rh, CC.APISetChatTTL(userId, chatType, id, chatItemTTL?.seconds)) + if (r.result is CR.CmdOk) return + throw Exception("failed to set chat TTL: ${r.responseType} ${r.details}") + } + suspend fun apiSetNetworkConfig(cfg: NetCfg, showAlertOnError: Boolean = true, ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}") - if (showAlertOnError) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.error_setting_network_config), - "${r.responseType}: ${r.details}" - ) - } - false - } + if (r.result is CR.CmdOk) return true + Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}") + if (showAlertOnError) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_setting_network_config), + "${r.responseType}: ${r.details}" + ) } + return false } suspend fun reconnectServer(rh: Long?, server: String): Boolean { val userId = currentUserId("reconnectServer") - return sendCommandOkResp(rh, CC.ReconnectServer(userId, server)) } @@ -1220,13 +1184,9 @@ object ChatController { suspend fun apiSetSettings(rh: Long?, type: ChatType, id: Long, settings: ChatSettings): Boolean { val r = sendCmd(rh, CC.APISetChatSettings(type, id, settings)) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}") - false - } - } + if (r.result is CR.CmdOk) return true + Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}") + return false } suspend fun apiSetNetworkInfo(networkInfo: UserNetworkInfo): Boolean = @@ -1237,207 +1197,199 @@ object ChatController { suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIContactInfo(contactId)) - if (r is CR.ContactInfo) return r.connectionStats_ to r.customUserProfile + if (r is API.Result && r.res is CR.ContactInfo) return r.res.connectionStats_ to r.res.customUserProfile Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") return null } suspend fun apiGroupMemberInfo(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIGroupMemberInfo(groupId, groupMemberId)) - if (r is CR.GroupMemberInfo) return Pair(r.member, r.connectionStats_) + if (r is API.Result && r.res is CR.GroupMemberInfo) return r.res.member to r.res.connectionStats_ Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") return null } suspend fun apiContactQueueInfo(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIContactQueueInfo(contactId)) - if (r is CR.QueueInfoR) return Pair(r.rcvMsgInfo, r.queueInfo) + if (r is API.Result && r.res is CR.QueueInfoR) return r.res.rcvMsgInfo to r.res.queueInfo apiErrorAlert("apiContactQueueInfo", generalGetString(MR.strings.error), r) return null } suspend fun apiGroupMemberQueueInfo(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIGroupMemberQueueInfo(groupId, groupMemberId)) - if (r is CR.QueueInfoR) return Pair(r.rcvMsgInfo, r.queueInfo) + if (r is API.Result && r.res is CR.QueueInfoR) return r.res.rcvMsgInfo to r.res.queueInfo apiErrorAlert("apiGroupMemberQueueInfo", generalGetString(MR.strings.error), r) return null } suspend fun apiSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { val r = sendCmd(rh, CC.APISwitchContact(contactId)) - if (r is CR.ContactSwitchStarted) return r.connectionStats + if (r is API.Result && r.res is CR.ContactSwitchStarted) return r.res.connectionStats apiErrorAlert("apiSwitchContact", generalGetString(MR.strings.error_changing_address), r) return null } suspend fun apiSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APISwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchStarted) return Pair(r.member, r.connectionStats) + if (r is API.Result && r.res is CR.GroupMemberSwitchStarted) return r.res.member to r.res.connectionStats apiErrorAlert("apiSwitchGroupMember", generalGetString(MR.strings.error_changing_address), r) return null } suspend fun apiAbortSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { val r = sendCmd(rh, CC.APIAbortSwitchContact(contactId)) - if (r is CR.ContactSwitchAborted) return r.connectionStats + if (r is API.Result && r.res is CR.ContactSwitchAborted) return r.res.connectionStats apiErrorAlert("apiAbortSwitchContact", generalGetString(MR.strings.error_aborting_address_change), r) return null } suspend fun apiAbortSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchAborted) return Pair(r.member, r.connectionStats) + if (r is API.Result && r.res is CR.GroupMemberSwitchAborted) return r.res.member to r.res.connectionStats apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(MR.strings.error_aborting_address_change), r) return null } suspend fun apiSyncContactRatchet(rh: Long?, contactId: Long, force: Boolean): ConnectionStats? { val r = sendCmd(rh, CC.APISyncContactRatchet(contactId, force)) - if (r is CR.ContactRatchetSyncStarted) return r.connectionStats + if (r is API.Result && r.res is CR.ContactRatchetSyncStarted) return r.res.connectionStats apiErrorAlert("apiSyncContactRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } suspend fun apiSyncGroupMemberRatchet(rh: Long?, groupId: Long, groupMemberId: Long, force: Boolean): Pair? { val r = sendCmd(rh, CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) - if (r is CR.GroupMemberRatchetSyncStarted) return Pair(r.member, r.connectionStats) + if (r is API.Result && r.res is CR.GroupMemberRatchetSyncStarted) return r.res.member to r.res.connectionStats apiErrorAlert("apiSyncGroupMemberRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } suspend fun apiGetContactCode(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIGetContactCode(contactId)) - if (r is CR.ContactCode) return r.contact to r.connectionCode + if (r is API.Result && r.res is CR.ContactCode) return r.res.contact to r.res.connectionCode Log.e(TAG,"failed to get contact code: ${r.responseType} ${r.details}") return null } suspend fun apiGetGroupMemberCode(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIGetGroupMemberCode(groupId, groupMemberId)) - if (r is CR.GroupMemberCode) return r.member to r.connectionCode + if (r is API.Result && r.res is CR.GroupMemberCode) return r.res.member to r.res.connectionCode Log.e(TAG,"failed to get group member code: ${r.responseType} ${r.details}") return null } suspend fun apiVerifyContact(rh: Long?, contactId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(rh, CC.APIVerifyContact(contactId, connectionCode))) { - is CR.ConnectionVerified -> r.verified to r.expectedCode - else -> null - } + val r = sendCmd(rh, CC.APIVerifyContact(contactId, connectionCode)) + if (r is API.Result && r.res is CR.ConnectionVerified) return r.res.verified to r.res.expectedCode + Log.e(TAG, "apiVerifyContact bad response: ${r.responseType} ${r.details}") + return null } suspend fun apiVerifyGroupMember(rh: Long?, groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(rh, CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { - is CR.ConnectionVerified -> r.verified to r.expectedCode - else -> null - } + val r = sendCmd(rh, CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode)) + if (r is API.Result && r.res is CR.ConnectionVerified) return r.res.verified to r.res.expectedCode + Log.e(TAG, "apiVerifyGroupMember bad response: ${r.responseType} ${r.details}") + return null } - - - 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)) - return when (r) { - is CR.Invitation -> (r.connReqInvitation to r.connection) to null - else -> { - if (!(networkErrorAlert(r))) { - return null to { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) } - } - null to null - } + val short = appPrefs.privacyShortLinks.get() + val r = sendCmd(rh, CC.APIAddContact(userId, short = short, incognito = incognito)) + return when { + r is API.Result && r.res is CR.Invitation -> (r.res.connLinkInvitation to r.res.connection) to null + !(networkErrorAlert(r)) -> null to { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) } + else -> null to null } } suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito)) - - return when (r) { - is CR.ConnectionIncognitoUpdated -> r.toConnection - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r) - } - null - } + if (r is API.Result && r.res is CR.ConnectionIncognitoUpdated) return r.res.toConnection + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r) } + return null } suspend fun apiChangeConnectionUser(rh: Long?, connId: Long, userId: Long): PendingContactConnection? { val r = sendCmd(rh, CC.ApiChangeConnectionUser(connId, userId)) - - return when (r) { - is CR.ConnectionUserChanged -> r.toConnection - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r) - } - null - } + if (r is API.Result && r.res is CR.ConnectionUserChanged) return r.res.toConnection + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r) } - } - - suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { - 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}") return null } - suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? { + suspend fun apiConnectPlan(rh: Long?, connLink: String): Pair? { + val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } + val r = sendCmd(rh, CC.APIConnectPlan(userId, connLink)) + if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan + apiConnectResponseAlert(r) + return null + } + + 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 API.Result && r.res is CR.SentConfirmation -> return r.res.connection + r is API.Result && r.res is CR.SentInvitation -> return r.res.connection + r is API.Result && r.res 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) + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.res.contact.displayName) ) - return null - } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat - && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { + else -> apiConnectResponseAlert(r) + } + return null + } + + private fun apiConnectResponseAlert(r: API) { + when { + r is API.Error && r.err is ChatError.ChatErrorChat + && r.err.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.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> { + r is API.Error && r.err is ChatError.ChatErrorChat + && r.err.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 API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.AUTH -> { AlertManager.shared.showAlertMsg( 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 - && r.chatError.agentError.smpErr is SMPErrorType.BLOCKED -> { + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.BLOCKED -> { showContentBlockedAlert( generalGetString(MR.strings.connection_error_blocked), - generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text), + generalGetString(MR.strings.connection_error_blocked_desc).format(r.err.agentError.smpErr.blockInfo.reason.text), ) - return null } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.QUOTA -> { + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.QUOTA -> { AlertManager.shared.showAlertMsg( 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 } } } @@ -1445,22 +1397,18 @@ object ChatController { suspend fun apiConnectContactViaAddress(rh: Long?, incognito: Boolean, contactId: Long): Contact? { val userId = try { currentUserId("apiConnectContactViaAddress") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.ApiConnectContactViaAddress(userId, incognito, contactId)) - when { - r is CR.SentInvitationToContact -> return r.contact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiConnectContactViaAddress", generalGetString(MR.strings.connection_error), r) - } - return null - } + if (r is API.Result && r.res is CR.SentInvitationToContact) return r.res.contact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiConnectContactViaAddress", generalGetString(MR.strings.connection_error), r) } + return null } 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) } } } @@ -1468,10 +1416,11 @@ object ChatController { suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean { chatModel.deletedChats.value += rh to type.type + id val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) + val res = r.result val success = when { - r is CR.ContactDeleted && type == ChatType.Direct -> true - r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true - r is CR.GroupDeletedUser && type == ChatType.Group -> true + res is CR.ContactDeleted && type == ChatType.Direct -> true + res is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true + res is CR.GroupDeletedUser && type == ChatType.Group -> true else -> { val titleId = when (type) { ChatType.Direct -> MR.strings.error_deleting_contact @@ -1492,13 +1441,12 @@ object ChatController { val type = ChatType.Direct chatModel.deletedChats.value += rh to type.type + id val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) - val contact = when { - r is CR.ContactDeleted -> r.contact - else -> { - val titleId = MR.strings.error_deleting_contact - apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) - null - } + val contact = if (r is API.Result && r.res is CR.ContactDeleted) { + r.res.contact + } else { + val titleId = MR.strings.error_deleting_contact + apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) + null } chatModel.deletedChats.value -= rh to type.type + id return contact @@ -1508,11 +1456,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() @@ -1522,7 +1470,7 @@ object ChatController { suspend fun apiClearChat(rh: Long?, type: ChatType, id: Long): ChatInfo? { val r = sendCmd(rh, CC.ApiClearChat(type, id)) - if (r is CR.ChatCleared) return r.chatInfo + if (r is API.Result && r.res is CR.ChatCleared) return r.res.chatInfo Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}") return null } @@ -1530,9 +1478,9 @@ object ChatController { suspend fun apiUpdateProfile(rh: Long?, profile: Profile): Pair>? { val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) - if (r is CR.UserProfileNoChange) return profile to emptyList() - if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { + if (r is API.Result && r.res is CR.UserProfileNoChange) return profile to emptyList() + if (r is API.Result && r.res is CR.UserProfileUpdated) return r.res.toProfile to r.res.updateSummary.changedContacts + if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") @@ -1541,73 +1489,70 @@ object ChatController { suspend fun apiSetProfileAddress(rh: Long?, on: Boolean): User? { val userId = try { currentUserId("apiSetProfileAddress") } catch (e: Exception) { return null } - return when (val r = sendCmd(rh, CC.ApiSetProfileAddress(userId, on))) { - is CR.UserProfileNoChange -> null - is CR.UserProfileUpdated -> r.user.updateRemoteHostId(rh) + val r = sendCmd(rh, CC.ApiSetProfileAddress(userId, on)) + return when { + r is API.Result && r.res is CR.UserProfileNoChange -> null + r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh) else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") } } suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? { val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs)) - if (r is CR.ContactPrefsUpdated) return r.toContact + if (r is API.Result && r.res is CR.ContactPrefsUpdated) return r.res.toContact Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetContactAlias(rh: Long?, contactId: Long, localAlias: String): Contact? { val r = sendCmd(rh, CC.ApiSetContactAlias(contactId, localAlias)) - if (r is CR.ContactAliasUpdated) return r.toContact + if (r is API.Result && r.res is CR.ContactAliasUpdated) return r.res.toContact Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetGroupAlias(rh: Long?, groupId: Long, localAlias: String): GroupInfo? { val r = sendCmd(rh, CC.ApiSetGroupAlias(groupId, localAlias)) - if (r is CR.GroupAliasUpdated) return r.toGroup + if (r is API.Result && r.res is CR.GroupAliasUpdated) return r.res.toGroup Log.e(TAG, "apiSetGroupAlias bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) - if (r is CR.ConnectionAliasUpdated) return r.toConnection + if (r is API.Result && r.res is CR.ConnectionAliasUpdated) return r.res.toConnection Log.e(TAG, "apiSetConnectionAlias bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetUserUIThemes(rh: Long?, userId: Long, themes: ThemeModeOverrides?): Boolean { val r = sendCmd(rh, CC.ApiSetUserUIThemes(userId, themes)) - if (r is CR.CmdOk) return true + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiSetUserUIThemes bad response: ${r.responseType} ${r.details}") return false } suspend fun apiSetChatUIThemes(rh: Long?, chatId: ChatId, themes: ThemeModeOverrides?): Boolean { val r = sendCmd(rh, CC.ApiSetChatUIThemes(chatId, themes)) - if (r is CR.CmdOk) return true + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiSetChatUIThemes bad response: ${r.responseType} ${r.details}") 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)) - return when (r) { - is CR.UserContactLinkCreated -> r.connReqContact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) - } - null - } + val r = sendCmd(rh, CC.ApiCreateMyAddress(userId, short)) + if (r is API.Result && r.res is CR.UserContactLinkCreated) return r.res.connLinkContact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) } + return null } suspend fun apiDeleteUserAddress(rh: Long?): User? { val userId = try { currentUserId("apiDeleteUserAddress") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.ApiDeleteMyAddress(userId)) - if (r is CR.UserContactLinkDeleted) return r.user.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.UserContactLinkDeleted) return r.res.user.updateRemoteHostId(rh) Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}") return null } @@ -1615,9 +1560,9 @@ object ChatController { private suspend fun apiGetUserAddress(rh: Long?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("apiGetUserAddress") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiShowMyAddress(userId)) - if (r is CR.UserContactLink) return r.contactLink - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore - && r.chatError.storeError is StoreError.UserContactLinkNotFound + if (r is API.Result && r.res is CR.UserContactLink) return r.res.contactLink + if (r is API.Error && r.err is ChatError.ChatErrorStore + && r.err.storeError is StoreError.UserContactLinkNotFound ) { return null } @@ -1625,12 +1570,22 @@ object ChatController { return null } + suspend fun apiAddMyAddressShortLink(rh: Long?): UserContactLinkRec? { + val userId = kotlin.runCatching { currentUserId("apiAddMyAddressShortLink") }.getOrElse { return null } + val r = sendCmd(rh, CC.ApiAddMyAddressShortLink(userId)) + if (r is API.Result && r.res is CR.UserContactLink) return r.res.contactLink + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAddMyAddressShortLink", generalGetString(MR.strings.error_creating_address), r) + } + return null + } + suspend fun userAddressAutoAccept(rh: Long?, autoAccept: AutoAccept?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiAddressAutoAccept(userId, autoAccept)) - if (r is CR.UserContactLinkUpdated) return r.contactLink - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore - && r.chatError.storeError is StoreError.UserContactLinkNotFound + if (r is API.Result && r.res is CR.UserContactLinkUpdated) return r.res.contactLink + if (r is API.Error && r.err is ChatError.ChatErrorStore + && r.err.storeError is StoreError.UserContactLinkNotFound ) { return null } @@ -1641,10 +1596,10 @@ object ChatController { suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? { val r = sendCmd(rh, CC.ApiAcceptContact(incognito, contactReqId)) return when { - r is CR.AcceptingContactRequest -> r.contact - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> { + r is API.Result && r.res is CR.AcceptingContactRequest -> r.res.contact + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.AUTH -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.sender_may_have_deleted_the_connection_request) @@ -1662,89 +1617,89 @@ object ChatController { suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Boolean { val r = sendCmd(rh, CC.ApiRejectContact(contactReqId)) - if (r is CR.ContactRequestRejected) return true + if (r is API.Result && r.res is CR.ContactRequestRejected) return true Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}") return false } suspend fun apiGetCallInvitations(rh: Long?): List { val r = sendCmd(rh, CC.ApiGetCallInvitations()) - if (r is CR.CallInvitations) return r.callInvitations + if (r is API.Result && r.res is CR.CallInvitations) return r.res.callInvitations Log.e(TAG, "apiGetCallInvitations bad response: ${r.responseType} ${r.details}") return emptyList() } suspend fun apiSendCallInvitation(rh: Long?, contact: Contact, callType: CallType): Boolean { val r = sendCmd(rh, CC.ApiSendCallInvitation(contact, callType)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiRejectCall(rh: Long?, contact: Contact): Boolean { val r = sendCmd(rh, CC.ApiRejectCall(contact)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiSendCallOffer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) val r = sendCmd(rh, CC.ApiSendCallOffer(contact, callOffer)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiSendCallAnswer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { val answer = WebRTCSession(rtcSession, rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallAnswer(contact, answer)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { val extraInfo = WebRTCExtraInfo(rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiEndCall(rh: Long?, contact: Contact): Boolean { val r = sendCmd(rh, CC.ApiEndCall(contact)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiCallStatus(rh: Long?, contact: Contact, status: WebRTCCallStatus): Boolean { val r = sendCmd(rh, CC.ApiCallStatus(contact, status)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiGetNetworkStatuses(rh: Long?): List? { val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) - if (r is CR.NetworkStatuses) return r.networkStatuses + if (r is API.Result && r.res is CR.NetworkStatuses) return r.res.networkStatuses Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { - val r = sendCmd(rh, CC.ApiChatRead(type, id)) - if (r is CR.CmdOk) return true + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List): Boolean { - val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds)) - if (r is CR.CmdOk) return true + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): ChatInfo? { + val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds)) + if (r is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}") - return false + return null } suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat)) - if (r is CR.CmdOk) return true + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiChatUnread bad response: ${r.responseType} ${r.details}") return false } suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { val r = sendCmd(null, CC.ApiUploadStandaloneFile(user.userId, file), ctrl) - return if (r is CR.SndStandaloneFileCreated) { - r.fileTransferMeta to null + return if (r is API.Result && r.res is CR.SndStandaloneFileCreated) { + r.res.fileTransferMeta to null } else { Log.e(TAG, "uploadStandaloneFile error: $r") null to r.toString() @@ -1753,8 +1708,8 @@ object ChatController { suspend fun downloadStandaloneFile(user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { val r = sendCmd(null, CC.ApiDownloadStandaloneFile(user.userId, url, file), ctrl) - return if (r is CR.RcvStandaloneFileCreated) { - r.rcvFileTransfer to null + return if (r is API.Result && r.res is CR.RcvStandaloneFileCreated) { + r.res.rcvFileTransfer to null } else { Log.e(TAG, "downloadStandaloneFile error: $r") null to r.toString() @@ -1763,8 +1718,8 @@ object ChatController { suspend fun standaloneFileInfo(url: String, ctrl: ChatCtrl? = null): MigrationFileLinkData? { val r = sendCmd(null, CC.ApiStandaloneFileInfo(url), ctrl) - return if (r is CR.StandaloneFileInfo) { - r.fileMeta + return if (r is API.Result && r.res is CR.StandaloneFileInfo) { + r.res.fileMeta } else { Log.e(TAG, "standaloneFileInfo error: $r") null @@ -1774,7 +1729,7 @@ object ChatController { suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, userApprovedRelays: Boolean = false, auto: Boolean = false) { val fileIdsToApprove = mutableListOf() val srvsToApprove = mutableSetOf() - val otherFileErrs = mutableListOf() + val otherFileErrs = mutableListOf() for (fileId in fileIds) { val r = sendCmd( @@ -1785,10 +1740,10 @@ object ChatController { inline = null ) ) - if (r is CR.RcvFileAccepted) { - chatItemSimpleUpdate(rhId, user, r.chatItem) + if (r is API.Result && r.res is CR.RcvFileAccepted) { + chatItemSimpleUpdate(rhId, user, r.res.chatItem) } else { - val maybeChatError = chatError(r) + val maybeChatError = apiChatErrorType(r) if (maybeChatError is ChatErrorType.FileNotApproved) { fileIdsToApprove.add(maybeChatError.fileId) srvsToApprove.addAll(maybeChatError.unknownServers.map { serverHostname(it) }) @@ -1816,21 +1771,19 @@ object ChatController { } ) } else if (otherFileErrs.size == 1) { // If there is a single other error, we differentiate on it - when (val errCR = otherFileErrs.first()) { - is CR.RcvFileAcceptedSndCancelled -> { - Log.d(TAG, "receiveFiles error: sender cancelled file transfer") - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.cannot_receive_file), - generalGetString(MR.strings.sender_cancelled_file_transfer) - ) - } - else -> { - val maybeChatError = chatError(errCR) - if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) { - Log.d(TAG, "receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") - } else { - apiErrorAlert("receiveFiles", generalGetString(MR.strings.error_receiving_file), errCR) - } + val errCR = otherFileErrs.first() + if (errCR is API.Result && errCR.res is CR.RcvFileAcceptedSndCancelled) { + Log.d(TAG, "receiveFiles error: sender cancelled file transfer") + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.cannot_receive_file), + generalGetString(MR.strings.sender_cancelled_file_transfer) + ) + } else { + val maybeChatError = apiChatErrorType(errCR) + if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) { + Log.d(TAG, "receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") + } else { + apiErrorAlert("receiveFiles", generalGetString(MR.strings.error_receiving_file), errCR) } } } else if (otherFileErrs.size > 1) { // If there are multiple other errors, we show general alert @@ -1846,7 +1799,7 @@ object ChatController { private fun showFilesToApproveAlert( srvsToApprove: Set, - otherFileErrs: List, + otherFileErrs: List, approveFiles: (() -> Unit) ) { val srvsToApproveStr = srvsToApprove.sorted().joinToString(separator = ", ") @@ -1909,9 +1862,9 @@ object ChatController { suspend fun apiCancelFile(rh: Long?, fileId: Long, ctrl: ChatCtrl? = null): AChatItem? { val r = sendCmd(rh, CC.CancelFile(fileId), ctrl) - return when (r) { - is CR.SndFileCancelled -> r.chatItem_ - is CR.RcvFileCancelled -> r.chatItem_ + return when { + r is API.Result && r.res is CR.SndFileCancelled -> r.res.chatItem_ + r is API.Result && r.res is CR.RcvFileCancelled -> r.res.chatItem_ else -> { Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}") null @@ -1922,34 +1875,32 @@ object ChatController { suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiNewGroup(userId, incognito, groupProfile)) - if (r is CR.GroupCreated) return r.groupInfo + if (r is API.Result && r.res is CR.GroupCreated) return r.res.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null } suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) - return when (r) { - is CR.SentGroupInvitation -> r.member - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiAddMember", generalGetString(MR.strings.error_adding_members), r) - } - null - } + if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAddMember", generalGetString(MR.strings.error_adding_members), r) } + return null } suspend fun apiJoinGroup(rh: Long?, groupId: Long) { val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) - when (r) { - is CR.UserAcceptedGroupSent -> - withChats { - updateGroup(rh, r.groupInfo) + when { + r is API.Result && r.res is CR.UserAcceptedGroupSent -> + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rh, r.res.groupInfo) + } + r is API.Error -> { + val e = r.err + suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { + withContext(Dispatchers.Main) { chatModel.chatsContext.removeChat(rh, "#$groupId") } } } - is CR.ChatCmdError -> { - val e = r.chatError - suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { withChats { 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)) @@ -1964,58 +1915,69 @@ object ChatController { } } - suspend fun apiRemoveMember(rh: Long?, groupId: Long, memberId: Long): GroupMember? = - when (val r = sendCmd(rh, CC.ApiRemoveMember(groupId, memberId))) { - is CR.UserDeletedMember -> r.member - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiRemoveMember", generalGetString(MR.strings.error_removing_member), r) - } - null - } + suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): Pair? { + val r = sendCmd(rh, CC.ApiAcceptMember(groupId, groupMemberId, memberRole)) + if (r is API.Result && r.res is CR.MemberAccepted) return r.res.groupInfo to r.res.member + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAcceptMember", generalGetString(MR.strings.error_accepting_member), r) } + return null + } - suspend fun apiMemberRole(rh: Long?, groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = - when (val r = sendCmd(rh, CC.ApiMemberRole(groupId, memberId, memberRole))) { - is CR.MemberRoleUser -> r.member - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiMemberRole", generalGetString(MR.strings.error_changing_role), r) - } - throw Exception("failed to change member role: ${r.responseType} ${r.details}") - } - } + suspend fun apiDeleteMemberSupportChat(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.ApiDeleteMemberSupportChat(groupId, groupMemberId)) + if (r is API.Result && r.res is CR.MemberSupportChatDeleted) return r.res.groupInfo to r.res.member + apiErrorAlert("apiDeleteMemberSupportChat", generalGetString(MR.strings.error_deleting_member_support_chat), r) + return null + } - suspend fun apiBlockMemberForAll(rh: Long?, groupId: Long, memberId: Long, blocked: Boolean): GroupMember = - when (val r = sendCmd(rh, CC.ApiBlockMemberForAll(groupId, memberId, blocked))) { - is CR.MemberBlockedForAllUser -> r.member - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiBlockMemberForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) - } - throw Exception("failed to block member for all: ${r.responseType} ${r.details}") - } + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): Pair>? { + val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) + if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r) } + return null + } + + suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List { + val r = sendCmd(rh, CC.ApiMembersRole(groupId, memberIds, memberRole)) + if (r is API.Result && r.res is CR.MembersRoleUser) return r.res.members + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiMembersRole", generalGetString(MR.strings.error_changing_role), r) + } + throw Exception("failed to change member role: ${r.responseType} ${r.details}") + } + + suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List { + val r = sendCmd(rh, CC.ApiBlockMembersForAll(groupId, memberIds, blocked)) + if (r is API.Result && r.res is CR.MembersBlockedForAllUser) return r.res.members + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiBlockMembersForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) + } + throw Exception("failed to block member for all: ${r.responseType} ${r.details}") + } suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? { val r = sendCmd(rh, CC.ApiLeaveGroup(groupId)) - if (r is CR.LeftMemberUser) return r.groupInfo + if (r is API.Result && r.res is CR.LeftMemberUser) return r.res.groupInfo Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}") return null } suspend fun apiListMembers(rh: Long?, groupId: Long): List { val r = sendCmd(rh, CC.ApiListMembers(groupId)) - if (r is CR.GroupMembers) return r.group.members + if (r is API.Result && r.res is CR.GroupMembers) return r.res.group.members Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}") return emptyList() } suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? { - return when (val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile))) { - is CR.GroupUpdated -> r.toGroup - is CR.ChatCmdError -> { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.chatError") + val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile)) + return when { + r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup + r is API.Error -> { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.err") null } else -> { @@ -2029,82 +1991,74 @@ 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 - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) - } - null - } + suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + val short = appPrefs.privacyShortLinks.get() + val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole, short)) + if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.connLinkContact to r.res.memberRole + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) } + return null } - 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 - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiGroupLinkMemberRole", generalGetString(MR.strings.error_updating_link_for_group), r) - } - null - } + suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole)) + if (r is API.Result && r.res is CR.GroupLink) return r.res.connLinkContact to r.res.memberRole + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiGroupLinkMemberRole", generalGetString(MR.strings.error_updating_link_for_group), r) } + return null } suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean { - return when (val r = sendCmd(rh, CC.APIDeleteGroupLink(groupId))) { - is CR.GroupLinkDeleted -> true - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiDeleteGroupLink", generalGetString(MR.strings.error_deleting_link_for_group), r) - } - false - } + val r = sendCmd(rh, CC.APIDeleteGroupLink(groupId)) + if (r is API.Result && r.res is CR.GroupLinkDeleted) return true + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiDeleteGroupLink", generalGetString(MR.strings.error_deleting_link_for_group), r) } + return false } - 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 - else -> { - Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") - null - } + suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { + val r = sendCmd(rh, CC.APIGetGroupLink(groupId)) + if (r is API.Result && r.res is CR.GroupLink) return r.res.connLinkContact to r.res.memberRole + Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiAddGroupShortLink(rh: Long?, groupId: Long): Pair? { + val r = sendCmd(rh, CC.ApiAddGroupShortLink(groupId)) + if (r is API.Result && r.res is CR.GroupLink) return r.res.connLinkContact to r.res.memberRole + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAddGroupShortLink", generalGetString(MR.strings.error_creating_link_for_group), r) } + return null } suspend fun apiCreateMemberContact(rh: Long?, groupId: Long, groupMemberId: Long): Contact? { - return when (val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId))) { - is CR.NewMemberContact -> r.contact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiCreateMemberContact", generalGetString(MR.strings.error_creating_member_contact), r) - } - null - } + val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId)) + if (r is API.Result && r.res is CR.NewMemberContact) return r.res.contact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiCreateMemberContact", generalGetString(MR.strings.error_creating_member_contact), r) } + return null } suspend fun apiSendMemberContactInvitation(rh: Long?, contactId: Long, mc: MsgContent): Contact? { - return when (val r = sendCmd(rh, CC.APISendMemberContactInvitation(contactId, mc))) { - is CR.NewMemberContactSentInv -> r.contact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiSendMemberContactInvitation", generalGetString(MR.strings.error_sending_message_contact_invitation), r) - } - null - } + val r = sendCmd(rh, CC.APISendMemberContactInvitation(contactId, mc)) + if (r is API.Result && r.res is CR.NewMemberContactSentInv) return r.res.contact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSendMemberContactInvitation", generalGetString(MR.strings.error_sending_message_contact_invitation), r) } + return null } suspend fun allowFeatureToContact(rh: Long?, contact: Contact, feature: ChatFeature, param: Int? = null) { 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) } } } @@ -2113,7 +2067,7 @@ object ChatController { suspend fun listRemoteHosts(): List? { val r = sendCmd(null, CC.ListRemoteHosts()) - if (r is CR.RemoteHostList) return r.remoteHosts + if (r is API.Result && r.res is CR.RemoteHostList) return r.res.remoteHosts apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } @@ -2126,14 +2080,14 @@ object ChatController { suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true, address: RemoteCtrlAddress?, port: Int?): CR.RemoteHostStarted? { val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast, address, port)) - if (r is CR.RemoteHostStarted) return r + if (r is API.Result && r.res is CR.RemoteHostStarted) return r.res apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun switchRemoteHost (rhId: Long?): RemoteHostInfo? { val r = sendCmd(null, CC.SwitchRemoteHost(rhId)) - if (r is CR.CurrentRemoteHost) return r.remoteHost_ + if (r is API.Result && r.res is CR.CurrentRemoteHost) return r.res.remoteHost_ apiErrorAlert("switchRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } @@ -2155,45 +2109,49 @@ object ChatController { suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? { val r = sendCmd(null, CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) - if (r is CR.RemoteFileStored) return r.remoteFileSource + if (r is API.Result && r.res is CR.RemoteFileStored) return r.res.remoteFileSource apiErrorAlert("storeRemoteFile", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCmd(null, CC.GetRemoteFile(rhId, file)) is CR.CmdOk + suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCmd(null, CC.GetRemoteFile(rhId, file)).result is CR.CmdOk - suspend fun connectRemoteCtrl(desktopAddress: String): Pair { + suspend fun connectRemoteCtrl(desktopAddress: String): Pair { val r = sendCmd(null, CC.ConnectRemoteCtrl(desktopAddress)) - return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null - else if (r is CR.ChatCmdError) null to r - else { - apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) - null to null + return when { + r is API.Result && r.res is CR.RemoteCtrlConnecting -> SomeRemoteCtrl(r.res.remoteCtrl_, r.res.ctrlAppInfo, r.res.appVersion) to null + r is API.Error -> null to r.err + else -> { + apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } } } suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rcId: Long): Pair { + suspend fun confirmRemoteCtrl(rcId: Long): Pair { val r = sendCmd(null, CC.ConfirmRemoteCtrl(remoteCtrlId = rcId)) - return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null - else if (r is CR.ChatCmdError) null to r - else { - apiErrorAlert("confirmRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) - null to null + return when { + r is API.Result && r.res is CR.RemoteCtrlConnecting -> SomeRemoteCtrl(r.res.remoteCtrl_, r.res.ctrlAppInfo, r.res.appVersion) to null + r is API.Error -> null to r.err + else -> { + apiErrorAlert("confirmRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } } } suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode)) - if (r is CR.RemoteCtrlConnected) return r.remoteCtrl + if (r is API.Result && r.res is CR.RemoteCtrlConnected) return r.res.remoteCtrl apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun listRemoteCtrls(): List? { val r = sendCmd(null, CC.ListRemoteCtrls()) - if (r is CR.RemoteCtrlList) return r.remoteCtrls + if (r is API.Result && r.res is CR.RemoteCtrlList) return r.res.remoteCtrls apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r) return null } @@ -2204,72 +2162,71 @@ object ChatController { private suspend fun sendCommandOkResp(rh: Long?, cmd: CC, ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(rh, cmd, ctrl) - val ok = r is CR.CmdOk + val ok = r is API.Result && r.res is CR.CmdOk if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok } suspend fun apiGetVersion(): CoreVersionInfo? { val r = sendCmd(null, CC.ShowVersion()) - return if (r is CR.VersionInfo) { - r.versionInfo - } else { - Log.e(TAG, "apiGetVersion bad response: ${r.responseType} ${r.details}") - null - } + if (r is API.Result && r.res is CR.VersionInfo) return r.res.versionInfo + Log.e(TAG, "apiGetVersion bad response: ${r.responseType} ${r.details}") + return null } - private fun networkErrorAlert(r: CR): Boolean { + private fun networkErrorAlert(r: API): Boolean { + if (r !is API.Error) return false + val e = r.err return when { - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.TIMEOUT -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.TIMEOUT -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_timeout), - String.format(generalGetString(MR.strings.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.NETWORK -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.NETWORK -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error), - String.format(generalGetString(MR.strings.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.HOST -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.HOST -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error), - String.format(generalGetString(MR.strings.network_error_broker_host_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_broker_host_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.TRANSPORT - && r.chatError.agentError.brokerErr.transportErr is SMPTransportError.Version -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.TRANSPORT + && e.agentError.brokerErr.transportErr is SMPTransportError.Version -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error), - String.format(generalGetString(MR.strings.network_error_broker_version_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_broker_version_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.PROXY -> - smpProxyErrorAlert(r.chatError.agentError.smpErr.proxyErr, r.chatError.agentError.serverAddress) - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.PROXY - && r.chatError.agentError.proxyErr is ProxyClientError.ProxyProtocolError - && r.chatError.agentError.proxyErr.protocolErr is SMPErrorType.PROXY -> + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.SMP + && e.agentError.smpErr is SMPErrorType.PROXY -> + smpProxyErrorAlert(e.agentError.smpErr.proxyErr, e.agentError.serverAddress) + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.PROXY + && e.agentError.proxyErr is ProxyClientError.ProxyProtocolError + && e.agentError.proxyErr.protocolErr is SMPErrorType.PROXY -> proxyDestinationErrorAlert( - r.chatError.agentError.proxyErr.protocolErr.proxyErr, - r.chatError.agentError.proxyServer, - r.chatError.agentError.relayServer + e.agentError.proxyErr.protocolErr.proxyErr, + e.agentError.proxyServer, + e.agentError.relayServer ) else -> false } @@ -2360,34 +2317,34 @@ object ChatController { } } - private fun apiErrorAlert(method: String, title: String, r: CR) { + private fun apiErrorAlert(method: String, title: String, r: API) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") AlertManager.shared.showAlertMsg(title, errMsg) } - private suspend fun processReceivedMsg(apiResp: APIResponse) { + private suspend fun processReceivedMsg(msg: API) { lastMsgReceivedTimestamp = System.currentTimeMillis() - val r = apiResp.resp - val rhId = apiResp.remoteHostId + val rhId = msg.rhId fun active(user: UserLike): Boolean = activeUser(rhId, user) - chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, msg)) + val r = msg.result 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) } } } @@ -2398,24 +2355,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) } } } @@ -2425,11 +2382,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())) } } } @@ -2438,18 +2395,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) } } } @@ -2458,8 +2415,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) } } } @@ -2470,8 +2427,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 @@ -2497,20 +2454,18 @@ 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 { - if (cItem.isReport) { - addChatItem(rhId, cInfo, cItem) - } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) } - } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { - withChats { - increaseUnreadCounter(rhId, r.user) + } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.increaseUnreadCounter(rhId, r.user) } } val file = cItem.file @@ -2531,13 +2486,11 @@ 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.upsertChatItem(rhId, cInfo, cItem) } - withReportsChatsIfOpen { - if (cItem.isReport) { - updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) - } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, cItem) } } } @@ -2545,22 +2498,20 @@ 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 { - if (r.reaction.chatReaction.chatItem.isReport) { - updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) - } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } } } is CR.ChatItemsDeleted -> { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> - if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) { - withChats { - decreaseUnreadCounter(rhId, r.user) + if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled(deletedChatItem.chatItem)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.decreaseUnreadCounter(rhId, r.user) } } } @@ -2583,79 +2534,38 @@ 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) { + chatModel.chatsContext.decreaseGroupReportsCounter(rhId, cInfo.id) } } - withReportsChatsIfOpen { - if (cItem.isReport) { - if (toChatItem == null) { - removeChatItem(rhId, cInfo, cItem) - } else { - upsertChatItem(rhId, cInfo, toChatItem.chatItem) - } + withContext(Dispatchers.Main) { + if (toChatItem == null) { + chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem) + } else { + chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } } + r.chatItemDeletions.lastOrNull()?.deletedChatItem?.chatInfo?.let { updatedChatInfo -> + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatInfo(rhId, updatedChatInfo) + } + } } is CR.GroupChatItemsDeleted -> { - if (!active(r.user)) { - val users = chatController.listUsers(rhId) - chatModel.users.clear() - chatModel.users.addAll(users) - return - } - val cInfo = ChatInfo.Group(r.groupInfo) - withChats { - r.chatItemIDs.forEach { itemId -> - decreaseGroupReportsCounter(rhId, cInfo.id) - 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) - } - val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id - if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { - ntfManager.cancelNotificationsForChat(cInfo.id) - ntfManager.displayNotification( - r.user, - cInfo.id, - cInfo.displayName, - generalGetString(MR.strings.marked_deleted_description) - ) - } - 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))) - } - } - 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) - } - 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))) - } - } + groupChatItemsDeleted(rhId, r) } 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 } @@ -2663,119 +2573,156 @@ 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.MemberAcceptedByOther -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } } 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) { + chatModel.chatsContext.removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + } + } + withContext(Dispatchers.Main) { + if (r.withMessages) { + 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.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + if (r.withMessages) { + 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) { + 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.updateGroup(rhId, r.groupInfo) + 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.MemberRoleUser -> + is CR.MembersRoleUser -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + r.members.forEach { member -> + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, member) + } } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + r.members.forEach { 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) + } + if ( + chatModel.chatId.value == r.groupInfo.id + && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) + && chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + ) { + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value = null + } + ModalManager.end.closeModals() } } 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) { @@ -2784,14 +2731,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 -> @@ -2892,26 +2839,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 -> { @@ -2920,12 +2867,13 @@ object ChatController { is CR.RemoteHostConnected -> { // TODO needs to update it instead in sessions chatModel.currentRemoteHost.value = r.remoteHost + ModalManager.start.closeModals() switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.ContactDisabled -> { if (active(r.user)) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } } @@ -3049,20 +2997,21 @@ 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 { - r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> { - chatModel.processedCriticalError.newError(r.chatError.agentError, r.chatError.agentError.offerRestart) - } - r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.INTERNAL && appPrefs.developerTools.get() && appPrefs.showInternalErrors.get() -> { - chatModel.processedInternalError.newError(r.chatError.agentError, false) - } - } else -> - Log.d(TAG , "unsupported event: ${r.responseType}") + Log.d(TAG , "unsupported event: ${msg.responseType}") + } + val e = (msg as? API.Error)?.err + when { + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.CRITICAL -> + chatModel.processedCriticalError.newError(e.agentError, e.agentError.offerRestart) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.INTERNAL && appPrefs.developerTools.get() && appPrefs.showInternalErrors.get() -> + chatModel.processedInternalError.newError(e.agentError, false) + else -> + Log.d(TAG , "unsupported event: ${msg.responseType}") } } @@ -3114,8 +3063,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) } } } @@ -3124,10 +3073,65 @@ object ChatController { if (activeUser(rh, user)) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem - withChats { upsertChatItem(rh, cInfo, cItem) } - withReportsChatsIfOpen { - if (cItem.isReport) { - upsertChatItem(rh, cInfo, cItem) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) + } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) + } + } + } + + suspend fun groupChatItemsDeleted(rhId: Long?, r: CR.GroupChatItemsDeleted) { + if (!activeUser(rhId, r.user)) { + val users = chatController.listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + return + } + val cInfo = ChatInfo.Group(r.groupInfo, groupChatScope = null) // TODO [knocking] get scope from items? + withContext(Dispatchers.Main) { + val chatsCtx = chatModel.chatsContext + r.chatItemIDs.forEach { itemId -> + 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 = chatsCtx.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { + ntfManager.cancelNotificationsForChat(cInfo.id) + ntfManager.displayNotification( + r.user, + cInfo.id, + cInfo.displayName, + generalGetString(MR.strings.marked_deleted_description) + ) + } + 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))) + } + } + 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))) } } } @@ -3140,8 +3144,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) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) + } if (createdChat) { notify() } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { @@ -3186,15 +3194,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) @@ -3231,11 +3239,12 @@ object ChatController { } else { null } - val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!) + val hostMode = appPrefs.networkHostMode.get() val requiredHostMode = appPrefs.networkRequiredHostMode.get() val sessionMode = appPrefs.networkSessionMode.get() - val smpProxyMode = SMPProxyMode.valueOf(appPrefs.networkSMPProxyMode.get()!!) - val smpProxyFallback = SMPProxyFallback.valueOf(appPrefs.networkSMPProxyFallback.get()!!) + val smpProxyMode = appPrefs.networkSMPProxyMode.get() + val smpProxyFallback = appPrefs.networkSMPProxyFallback.get() + val smpWebPortServers = appPrefs.networkSMPWebPortServers.get() val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val tcpTimeoutPerKb = appPrefs.networkTCPTimeoutPerKb.get() @@ -3258,6 +3267,7 @@ object ChatController { sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, + smpWebPortServers = smpWebPortServers, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpTimeoutPerKb = tcpTimeoutPerKb, @@ -3273,11 +3283,12 @@ object ChatController { * */ fun setNetCfg(cfg: NetCfg) { appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy) - appPrefs.networkHostMode.set(cfg.hostMode.name) + appPrefs.networkHostMode.set(cfg.hostMode) appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode) appPrefs.networkSessionMode.set(cfg.sessionMode) - appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode.name) - appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback.name) + appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode) + appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback) + appPrefs.networkSMPWebPortServers.set(cfg.smpWebPortServers) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkTCPTimeoutPerKb.set(cfg.tcpTimeoutPerKb) @@ -3342,9 +3353,9 @@ sealed class CC { class ApiGetSettings(val settings: AppSettings): CC() class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() - class ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() - class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() + class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3352,26 +3363,31 @@ sealed class CC { class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() - class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() - class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() + class ApiUpdateChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC() + class ApiDeleteChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() - class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() + class ApiArchiveReceivedReports(val groupId: Long): CC() + class ApiDeleteReceivedReports(val groupId: Long, val itemIds: List, val mode: CIDeleteMode): CC() + class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() - class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val chatItemIds: List): CC() - class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List, val ttl: Int?): CC() + class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() + class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() - class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() - class ApiBlockMemberForAll(val groupId: Long, val memberId: Long, val blocked: Boolean): CC() - class ApiRemoveMember(val groupId: Long, val memberId: Long): CC() + class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() + class ApiDeleteMemberSupportChat(val groupId: Long, val groupMemberId: Long): CC() + class ApiMembersRole(val groupId: Long, val memberIds: List, val memberRole: GroupMemberRole): CC() + class ApiBlockMembersForAll(val groupId: Long, val memberIds: List, val blocked: Boolean): CC() + class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean): 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() + class ApiAddGroupShortLink(val groupId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() class APITestProtoServer(val userId: Long, val server: String): CC() @@ -3383,8 +3399,9 @@ sealed class CC { class ApiGetUsageConditions(): CC() class ApiSetConditionsNotified(val conditionsId: Long): CC() class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List): CC() - class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() + class APISetChatItemTTL(val userId: Long, val seconds: Long): CC() class APIGetChatItemTTL(val userId: Long): CC() + class APISetChatTTL(val userId: Long, val chatType: ChatType, val id: Long, val seconds: Long?): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC() @@ -3406,11 +3423,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() @@ -3422,9 +3439,10 @@ 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 ApiAddMyAddressShortLink(val userId: Long): CC() class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC() class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC() class ApiGetCallInvitations: CC() @@ -3438,8 +3456,8 @@ sealed class CC { class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() - class ApiChatRead(val type: ChatType, val id: Long): CC() - class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List): CC() + class ApiChatRead(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() + class ApiChatItemsRead(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() @@ -3511,16 +3529,16 @@ sealed class CC { } else { " content=${contentTag.name.lowercase()}" } - "/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + "/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") } - is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" + is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" - is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}" + is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" is ApiDeleteChatTag -> "/_delete tag $tagId" is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}" is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}" @@ -3529,31 +3547,36 @@ sealed class CC { "/_create *$noteFolderId json $msgs" } is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" - is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" - is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" + is ApiUpdateChatItem -> "/_update item ${chatRef(type, id, scope)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}" + is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id, scope)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" - is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" + is ApiArchiveReceivedReports -> "/_archive reports #$groupId" + is ApiDeleteReceivedReports -> "/_delete reports #$groupId ${itemIds.joinToString(",")} ${mode.deleteMode}" + is ApiChatItemReaction -> "/_reaction ${chatRef(type, id, scope)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { val ttlStr = if (ttl != null) "$ttl" else "default" - "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + "/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } is ApiPlanForwardChatItems -> { - "/_forward plan ${chatRef(fromChatType, fromChatId)} ${chatItemIds.joinToString(",")}" + "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" - is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" - is ApiBlockMemberForAll -> "/_block #$groupId $memberId blocked=${onOff(blocked)}" - is ApiRemoveMember -> "/_remove #$groupId $memberId" + is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" + is ApiDeleteMemberSupportChat -> "/_delete member chat #$groupId $groupMemberId" + is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" + is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" + is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}" 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" + is ApiAddGroupShortLink -> "/_short link #$groupId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" is APITestProtoServer -> "/_server test $userId $server" @@ -3567,12 +3590,13 @@ sealed class CC { is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" + is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id, scope = null)} ${chatItemTTLStr(seconds)}" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}" is ReconnectServer -> "/reconnect $userId $server" is ReconnectAllServers -> "/reconnect" - is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" + is APISetChatSettings -> "/_settings ${chatRef(type, id, scope = null)} ${json.encodeToString(chatSettings)}" is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" @@ -3588,14 +3612,14 @@ 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)}" + is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}" + is ApiClearChat -> "/_clear chat ${chatRef(type, id, scope = null)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" @@ -3604,9 +3628,10 @@ 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 ApiAddMyAddressShortLink -> "/_short_link_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}" is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId" @@ -3620,13 +3645,13 @@ sealed class CC { is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiGetNetworkStatuses -> "/_network_statuses" - is ApiChatRead -> "/_read chat ${chatRef(type, id)}" - is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}" - is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" + is ApiChatRead -> "/_read chat ${chatRef(type, id, scope)}" + is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id, scope)} ${itemIds.joinToString(",")}" + is ApiChatUnread -> "/_unread chat ${chatRef(type, id, scope = null)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId" + - (" approved_relays=${onOff(userApprovedRelays)}") + - (if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") + + " approved_relays=${onOff(userApprovedRelays)}" + + " encrypt=${onOff(encrypt)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is SetLocalDeviceName -> "/set device name $displayName" @@ -3697,6 +3722,8 @@ sealed class CC { is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" + is ApiArchiveReceivedReports -> "apiArchiveReceivedReports" + is ApiDeleteReceivedReports -> "apiDeleteReceivedReports" is ApiChatItemReaction -> "apiChatItemReaction" is ApiGetReactionMembers -> "apiGetReactionMembers" is ApiForwardChatItems -> "apiForwardChatItems" @@ -3704,9 +3731,11 @@ sealed class CC { is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" - is ApiMemberRole -> "apiMemberRole" - is ApiBlockMemberForAll -> "apiBlockMemberForAll" - is ApiRemoveMember -> "apiRemoveMember" + is ApiAcceptMember -> "apiAcceptMember" + is ApiDeleteMemberSupportChat -> "apiDeleteMemberSupportChat" + is ApiMembersRole -> "apiMembersRole" + is ApiBlockMembersForAll -> "apiBlockMembersForAll" + is ApiRemoveMembers -> "apiRemoveMembers" is ApiLeaveGroup -> "apiLeaveGroup" is ApiListMembers -> "apiListMembers" is ApiUpdateGroupProfile -> "apiUpdateGroupProfile" @@ -3714,6 +3743,7 @@ sealed class CC { is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole" is APIDeleteGroupLink -> "apiDeleteGroupLink" is APIGetGroupLink -> "apiGetGroupLink" + is ApiAddGroupShortLink -> "apiAddGroupShortLink" is APICreateMemberContact -> "apiCreateMemberContact" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APITestProtoServer -> "testProtoServer" @@ -3727,6 +3757,7 @@ sealed class CC { is ApiAcceptConditions -> "apiAcceptConditions" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" + is APISetChatTTL -> "apiSetChatTTL" is APISetNetworkConfig -> "apiSetNetworkConfig" is APIGetNetworkConfig -> "apiGetNetworkConfig" is APISetNetworkInfo -> "apiSetNetworkInfo" @@ -3767,6 +3798,7 @@ sealed class CC { is ApiCreateMyAddress -> "apiCreateMyAddress" is ApiDeleteMyAddress -> "apiDeleteMyAddress" is ApiShowMyAddress -> "apiShowMyAddress" + is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink" is ApiSetProfileAddress -> "apiSetProfileAddress" is ApiAddressAutoAccept -> "apiAddressAutoAccept" is ApiAcceptContact -> "apiAcceptContact" @@ -3812,7 +3844,7 @@ sealed class CC { data class ItemRange(val from: Long, val to: Long) fun chatItemTTLStr(seconds: Long?): String { - if (seconds == null) return "none" + if (seconds == null) return "default" return seconds.toString() } @@ -3839,7 +3871,13 @@ sealed class CC { private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd) companion object { - fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" + fun chatRef(chatType: ChatType, id: Long, scope: GroupChatScope?) = when (scope) { + null -> "${chatType.type}${id}" + is GroupChatScope.MemberSupport -> when (scope.groupMemberId_) { + null -> "${chatType.type}${id}(_support)" + else -> "${chatType.type}${id}(_support:${scope.groupMemberId_})" + } + } } } @@ -3874,7 +3912,13 @@ sealed class ChatPagination { } @Serializable -class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) +class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent, val mentions: Map) + +@Serializable +class UpdatedMessage(val msgContent: MsgContent, val mentions: Map) { + val cmdString: String get() = + if (msgContent is MCUnknown) "json $json" else "json ${json.encodeToString(this)}" +} @Serializable class ChatTagData(val emoji: String?, val text: String) @@ -4364,13 +4408,13 @@ data class ParsedServerAddress ( @Serializable data class NetCfg( val socksProxy: String?, - val socksMode: SocksMode = SocksMode.Always, - val hostMode: HostMode = HostMode.OnionViaSocks, + val socksMode: SocksMode = SocksMode.default, + val hostMode: HostMode = HostMode.default, val requiredHostMode: Boolean = false, val sessionMode: TransportSessionMode = TransportSessionMode.default, - val smpProxyMode: SMPProxyMode = SMPProxyMode.Always, - val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected, - val smpWebPort: Boolean = false, + val smpProxyMode: SMPProxyMode = SMPProxyMode.default, + val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.default, + val smpWebPortServers: SMPWebPortServers = SMPWebPortServers.default, val tcpConnectTimeout: Long, // microseconds val tcpTimeout: Long, // microseconds val tcpTimeoutPerKb: Long, // microseconds @@ -4468,12 +4512,20 @@ enum class HostMode { @SerialName("onionViaSocks") OnionViaSocks, @SerialName("onion") Onion, @SerialName("public") Public; + + companion object { + val default = OnionViaSocks + } } @Serializable enum class SocksMode { @SerialName("always") Always, @SerialName("onion") Onion; + + companion object { + val default = Always + } } @Serializable @@ -4482,6 +4534,10 @@ enum class SMPProxyMode { @SerialName("unknown") Unknown, @SerialName("unprotected") Unprotected, @SerialName("never") Never; + + companion object { + val default = Always + } } @Serializable @@ -4489,6 +4545,27 @@ enum class SMPProxyFallback { @SerialName("allow") Allow, @SerialName("allowProtected") AllowProtected, @SerialName("prohibit") Prohibit; + + companion object { + val default = AllowProtected + } +} + +@Serializable +enum class SMPWebPortServers { + @SerialName("all") All, + @SerialName("preset") Preset, + @SerialName("off") Off; + + val text get(): StringResource = when (this) { + All -> MR.strings.network_smp_web_port_all + Preset -> MR.strings.network_smp_web_port_preset + Off -> MR.strings.network_smp_web_port_off + } + + companion object { + val default = Preset + } } @Serializable @@ -4531,7 +4608,37 @@ data class ChatSettings( enum class MsgFilter { @SerialName("all") All, @SerialName("none") None, - @SerialName("mentions") Mentions, + @SerialName("mentions") Mentions; + + fun nextMode(mentions: Boolean): MsgFilter { + return when (this) { + All -> if (mentions) Mentions else None + Mentions -> None + None -> All + } + } + + fun text(mentions: Boolean): StringResource { + return when (this) { + All -> MR.strings.unmute_chat + Mentions -> MR.strings.mute_chat + None -> if (mentions) MR.strings.mute_all_chat else MR.strings.mute_chat + } + } + + val icon: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important + None -> MR.images.ic_notifications_off + } + + val iconFilled: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important_filled + None -> MR.images.ic_notifications_off_filled + } } @Serializable @@ -5101,6 +5208,7 @@ enum class GroupFeature: Feature { @SerialName("voice") Voice, @SerialName("files") Files, @SerialName("simplexLinks") SimplexLinks, + @SerialName("reports") Reports, @SerialName("history") History; override val hasParam: Boolean get() = when(this) { @@ -5117,6 +5225,7 @@ enum class GroupFeature: Feature { Voice -> true Files -> true SimplexLinks -> true + Reports -> false History -> false } @@ -5129,6 +5238,7 @@ enum class GroupFeature: Feature { Voice -> generalGetString(MR.strings.voice_messages) Files -> generalGetString(MR.strings.files_and_media) SimplexLinks -> generalGetString(MR.strings.simplex_links) + Reports -> generalGetString(MR.strings.group_reports_member_reports) History -> generalGetString(MR.strings.recent_history) } @@ -5141,6 +5251,7 @@ enum class GroupFeature: Feature { Voice -> painterResource(MR.images.ic_keyboard_voice) Files -> painterResource(MR.images.ic_draft) SimplexLinks -> painterResource(MR.images.ic_link) + Reports -> painterResource(MR.images.ic_flag) History -> painterResource(MR.images.ic_schedule) } @@ -5153,6 +5264,7 @@ enum class GroupFeature: Feature { Voice -> painterResource(MR.images.ic_keyboard_voice_filled) Files -> painterResource(MR.images.ic_draft_filled) SimplexLinks -> painterResource(MR.images.ic_link) + Reports -> painterResource(MR.images.ic_flag_filled) History -> painterResource(MR.images.ic_schedule_filled) } @@ -5187,6 +5299,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_simplex_links) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_simplex_links) } + Reports -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_member_reports) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_member_reports) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_recent_history) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_recent_history) @@ -5222,6 +5338,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_simplex_links) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.simplex_links_are_prohibited_in_group) } + Reports -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_reports) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.member_reports_are_prohibited) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.recent_history_is_sent_to_new_members) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.recent_history_is_not_sent_to_new_members) @@ -5341,6 +5461,7 @@ data class FullGroupPreferences( val voice: RoleGroupPreference, val files: RoleGroupPreference, val simplexLinks: RoleGroupPreference, + val reports: GroupPreference, val history: GroupPreference, ) { fun toGroupPreferences(): GroupPreferences = @@ -5352,7 +5473,8 @@ data class FullGroupPreferences( voice = voice, files = files, simplexLinks = simplexLinks, - history = history + reports = reports, + history = history, ) companion object { @@ -5364,6 +5486,7 @@ data class FullGroupPreferences( voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), ) } @@ -5378,6 +5501,7 @@ data class GroupPreferences( val voice: RoleGroupPreference? = null, val files: RoleGroupPreference? = null, val simplexLinks: RoleGroupPreference? = null, + val reports: GroupPreference? = null, val history: GroupPreference? = null, ) { companion object { @@ -5389,6 +5513,7 @@ data class GroupPreferences( voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), ) } @@ -5530,56 +5655,122 @@ val yaml = Yaml(configuration = YamlConfiguration( codePointLimit = 5500000, )) -@Serializable -class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = null) { - companion object { - fun decodeStr(str: String): APIResponse { - return try { - json.decodeFromString(str) - } catch(e: Throwable) { - try { - Log.d(TAG, e.localizedMessage ?: "") - val data = json.parseToJsonElement(str).jsonObject - val resp = data["resp"]!!.jsonObject - val type = resp["type"]?.jsonPrimitive?.contentOrNull ?: "invalid" - val corr = data["corr"]?.toString() - val remoteHostId = data["remoteHostId"]?.jsonPrimitive?.longOrNull - try { - if (type == "apiChats") { - val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) - val chats: List = resp["chats"]!!.jsonArray.map { - parseChatData(it) - } - return APIResponse(CR.ApiChats(user, chats), remoteHostId, corr) - } else if (type == "apiChat") { - val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) - val chat = parseChatData(resp["chat"]!!) - return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr) - } else if (type == "chatCmdError") { - val userObject = resp["user_"]?.jsonObject - val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse(CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) - } else if (type == "chatError") { - val userObject = resp["user_"]?.jsonObject - val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) - } - } catch (e: Exception) { - Log.e(TAG, "Exception while parsing chat(s): " + e.stackTraceToString()) - } catch (e: Throwable) { - Log.e(TAG, "Throwable while parsing chat(s): " + e.stackTraceToString()) - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) +@Suppress("SERIALIZER_TYPE_INCOMPATIBLE") +@Serializable(with = APISerializer::class) +sealed class API { + @Serializable(with = APISerializer::class) class Result(val remoteHostId: Long?, val res: CR) : API() + @Serializable(with = APISerializer::class) class Error(val remoteHostId: Long?, val err: ChatError) : API() + + val ok: Boolean get() = this is API.Result && this.res is CR.CmdOk + val result: CR? get() = (this as? API.Result)?.res + val rhId: Long? get() = when (this) { + is Result -> remoteHostId + is Error -> remoteHostId + } + + val pair: Pair get() = when (this) { + is Result -> res to null + is Error -> null to err + } + + val responseType: String get() = when (this) { + is Result -> res.responseType + is Error -> "error ${err.resultType}" + } + + val details: String get() = when (this) { + is Result -> res.details + is Error -> "error ${err.string}" + } +} + +object APISerializer : KSerializer { + override val descriptor: SerialDescriptor = buildSerialDescriptor("API", PolymorphicKind.SEALED) { + element("Result", buildClassSerialDescriptor("Result") { + element("remoteHostId") + element("result") + }) + element("Error", buildClassSerialDescriptor("Error") { + element("remoteHostId") + element("error") + }) + } + + override fun deserialize(decoder: Decoder): API { + require(decoder is JsonDecoder) + val j = try { decoder.decodeJsonElement() } catch(e: Exception) { null } catch(e: Throwable) { null } + if (j == null) return API.Error(remoteHostId = null, ChatError.ChatErrorInvalidJSON("")) + if (j !is JsonObject) return API.Error(remoteHostId = null, ChatError.ChatErrorInvalidJSON(json.encodeToString(j))) + val remoteHostId = j["remoteHostId"]?.jsonPrimitive?.longOrNull + val jRes = j["result"] + if (jRes != null) { + val result = try { + decoder.json.decodeFromJsonElement(jRes) + } catch (e: Exception) { + fallbackResult(jRes) + } catch (e: Throwable) { + fallbackResult(jRes) + } + return API.Result(remoteHostId, result) + } + val jErr = j["error"] + if (jErr != null) { + val error = try { + decoder.json.decodeFromJsonElement(jErr) + } catch (e: Exception) { + fallbackChatError(jErr) + } catch (e: Throwable) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + fallbackChatError(jErr) + } + return API.Error(remoteHostId, error) + } + return API.Error(remoteHostId, fallbackChatError(j)) + } + + private fun fallbackResult(jRes: JsonElement): CR { + if (jRes is JsonObject) { + val type = jRes["type"]?.jsonPrimitive?.contentOrNull ?: "invalid" + try { + if (type == "apiChats") { + val user: UserRef = json.decodeFromJsonElement(jRes["user"]!!.jsonObject) + val chats: List = jRes["chats"]!!.jsonArray.map { + parseChatData(it) } - APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr) - } catch(e: Exception) { - APIResponse(CR.Invalid(str), remoteHostId = null) - } catch(e: Throwable) { - Log.e(TAG, "Throwable2 while parsing chat(s): " + e.stackTraceToString()) - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) - APIResponse(CR.Invalid(str), remoteHostId = null) + return CR.ApiChats(user, chats) + } else if (type == "apiChat") { + val user: UserRef = json.decodeFromJsonElement(jRes["user"]!!.jsonObject) + val chat = parseChatData(jRes["chat"]!!) + return CR.ApiChat(user, chat) } + } catch (e: Exception) { + Log.e(TAG, "Exception while parsing chat(s): " + e.stackTraceToString()) + } catch (e: Throwable) { + Log.e(TAG, "Throwable while parsing chat(s): " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } + return CR.Response(type, json.encodeToString(jRes)) + } + return CR.Response(type = "invalid", json.encodeToString(jRes)) + } + + private fun fallbackChatError(jErr: JsonElement): ChatError { + return ChatError.ChatErrorInvalidJSON(json.encodeToString(jErr)) + } + + override fun serialize(encoder: Encoder, value: API) { + require(encoder is JsonEncoder) + val json = when (value) { + is API.Result -> buildJsonObject { + value.remoteHostId?.let { put("remoteHostId", it) } + put("result", encoder.json.encodeToJsonElement(value.res)) + } + is API.Error -> buildJsonObject { + value.remoteHostId?.let { put("remoteHostId", it) } + put("error", encoder.json.encodeToJsonElement(value.err)) } } + encoder.encodeJsonElement(json) } } @@ -5634,22 +5825,21 @@ sealed class CR { @Serializable @SerialName("groupMemberRatchetSyncStarted") class GroupMemberRatchetSyncStarted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() @Serializable @SerialName("contactRatchetSync") class ContactRatchetSync(val user: UserRef, val contact: Contact, val ratchetSyncProgress: RatchetSyncProgress): CR() @Serializable @SerialName("groupMemberRatchetSync") class GroupMemberRatchetSync(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val ratchetSyncProgress: RatchetSyncProgress): CR() - @Serializable @SerialName("contactVerificationReset") class ContactVerificationReset(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("groupMemberVerificationReset") class GroupMemberVerificationReset(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): 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() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("itemsReadForChat") class ItemsReadForChat(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @@ -5660,7 +5850,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() @@ -5677,10 +5867,6 @@ sealed class CR { // TODO remove above @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR() - @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR() - @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() - @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemsStatusesUpdated") class ChatItemsStatusesUpdated(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR() @@ -5696,29 +5882,30 @@ sealed class CR { @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("businessLinkConnecting") class BusinessLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val fromContact: Contact): CR() - @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("userDeletedMembers") class UserDeletedMembers(val user: UserRef, val groupInfo: GroupInfo, val members: List, val withMessages: Boolean): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberSupportChatDeleted") class MemberSupportChatDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() - @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() + @Serializable @SerialName("membersRoleUser") class MembersRoleUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val toRole: GroupMemberRole): CR() @Serializable @SerialName("memberBlockedForAll") class MemberBlockedForAll(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val blocked: Boolean): CR() - @Serializable @SerialName("memberBlockedForAllUser") class MemberBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val blocked: Boolean): CR() - @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() + @Serializable @SerialName("membersBlockedForAllUser") class MembersBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val blocked: Boolean): CR() + @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val withMessages: Boolean): CR() + @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: UserRef, val intoContact: Contact, val mergedContact: Contact): CR() - @Serializable @SerialName("groupInvitation") class GroupInvitation(val user: UserRef, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): 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() @@ -5781,8 +5968,6 @@ sealed class CR { // misc @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() - @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() - @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveExported") class ArchiveExported(val archiveErrors: List): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() @Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR() @@ -5822,8 +6007,6 @@ sealed class CR { is GroupMemberRatchetSyncStarted -> "groupMemberRatchetSyncStarted" is ContactRatchetSync -> "contactRatchetSync" is GroupMemberRatchetSync -> "groupMemberRatchetSync" - is ContactVerificationReset -> "contactVerificationReset" - is GroupMemberVerificationReset -> "groupMemberVerificationReset" is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" @@ -5838,6 +6021,7 @@ sealed class CR { is ContactAlreadyExists -> "contactAlreadyExists" is ContactDeleted -> "contactDeleted" is ContactDeletedByContact -> "contactDeletedByContact" + is ItemsReadForChat -> "itemsReadForChat" is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" @@ -5863,10 +6047,6 @@ sealed class CR { is ContactSubSummary -> "contactSubSummary" is NetworkStatusResp -> "networkStatus" is NetworkStatuses -> "networkStatuses" - is GroupSubscribed -> "groupSubscribed" - is MemberSubErrors -> "memberSubErrors" - is GroupEmpty -> "groupEmpty" - is UserContactLinkSubscribed -> "userContactLinkSubscribed" is NewChatItems -> "newChatItems" is ChatItemsStatusesUpdated -> "chatItemsStatusesUpdated" is ChatItemUpdated -> "chatItemUpdated" @@ -5881,26 +6061,27 @@ sealed class CR { is UserAcceptedGroupSent -> "userAcceptedGroupSent" is GroupLinkConnecting -> "groupLinkConnecting" is BusinessLinkConnecting -> "businessLinkConnecting" - is UserDeletedMember -> "userDeletedMember" + is UserDeletedMembers -> "userDeletedMembers" is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" is ReceivedGroupInvitation -> "receivedGroupInvitation" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" + is MemberAccepted -> "memberAccepted" + is MemberSupportChatDeleted -> "memberSupportChatDeleted" + is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" - is MemberRoleUser -> "memberRoleUser" + is MembersRoleUser -> "membersRoleUser" is MemberBlockedForAll -> "memberBlockedForAll" - is MemberBlockedForAllUser -> "memberBlockedForAllUser" + is MembersBlockedForAllUser -> "membersBlockedForAllUser" is DeletedMemberUser -> "deletedMemberUser" is DeletedMember -> "deletedMember" is LeftMember -> "leftMember" is GroupDeleted -> "groupDeleted" is ContactsMerged -> "contactsMerged" - is GroupInvitation -> "groupInvitation" is UserJoinedGroup -> "userJoinedGroup" is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" - is GroupRemoved -> "groupRemoved" is GroupUpdated -> "groupUpdated" is GroupLinkCreated -> "groupLinkCreated" is GroupLink -> "groupLink" @@ -5961,8 +6142,6 @@ sealed class CR { is AgentSubsTotal -> "agentSubsTotal" is AgentServersSummary -> "agentServersSummary" is CmdOk -> "cmdOk" - is ChatCmdError -> "chatCmdError" - is ChatRespError -> "chatError" is ArchiveExported -> "archiveExported" is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" @@ -6000,22 +6179,21 @@ sealed class CR { is GroupMemberRatchetSyncStarted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") is ContactRatchetSync -> withUser(user, "contact: ${json.encodeToString(contact)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") is GroupMemberRatchetSync -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") - is ContactVerificationReset -> withUser(user, "contact: ${json.encodeToString(contact)}") - is GroupMemberVerificationReset -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}") is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") 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)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact)) is ContactDeletedByContact -> withUser(user, json.encodeToString(contact)) + is ItemsReadForChat -> withUser(user, json.encodeToString(chatInfo)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) @@ -6026,7 +6204,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)) @@ -6041,10 +6219,6 @@ sealed class CR { is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) - is GroupSubscribed -> withUser(user, json.encodeToString(group)) - is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) - is GroupEmpty -> withUser(user, json.encodeToString(group)) - is UserContactLinkSubscribed -> noDetails() is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemsStatusesUpdated -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) @@ -6059,29 +6233,30 @@ sealed class CR { is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") is BusinessLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nfromContact: $fromContact") - is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is UserDeletedMembers -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\nwithMessages: $withMessages") is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") + is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberSupportChatDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") - is MemberRoleUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") + is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole") is MemberBlockedForAll -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nblocked: $blocked") - is MemberBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nblocked: $blocked") - is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member") - is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember") + is MembersBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\nblocked: $blocked") + is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nwithMessages: ${withMessages}") + is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember\nwithMessages: ${withMessages}") is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") - is GroupInvitation -> withUser(user, json.encodeToString(groupInfo)) is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") 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") @@ -6156,8 +6331,6 @@ sealed class CR { "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + "agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}" is CmdOk -> withUser(user, noDetails()) - is ChatCmdError -> withUser(user_, chatError.string) - is ChatRespError -> withUser(user_, chatError.string) is ArchiveExported -> "${archiveErrors.map { it.string } }" is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) @@ -6170,13 +6343,9 @@ sealed class CR { private fun withUser(u: UserLike?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s } -fun chatError(r: CR): ChatErrorType? { - return ( - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat) r.chatError.errorType - else if (r is CR.ChatRespError && r.chatError is ChatError.ChatErrorChat) r.chatError.errorType - else null - ) -} +fun apiChatErrorType(r: API): ChatErrorType? = + if (r is API.Error && r.err is ChatError.ChatErrorChat) r.err.errorType + else null @Serializable sealed class ChatDeleteMode { @@ -6191,11 +6360,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 @@ -6238,7 +6430,7 @@ abstract class TerminalItem { override val details get() = cmd.cmdString } - class Resp(override val id: Long, override val remoteHostId: Long?, val resp: CR): TerminalItem() { + class Resp(override val id: Long, override val remoteHostId: Long?, val resp: API): TerminalItem() { override val label get() = "< ${resp.responseType}" override val details get() = resp.details } @@ -6246,11 +6438,11 @@ abstract class TerminalItem { companion object { val sampleData = listOf( Cmd(0, null, CC.ShowActiveUser()), - Resp(1, null, CR.ActiveUser(User.sampleData)) + Resp(1, null, API.Result(null, CR.ActiveUser(User.sampleData))) ) fun cmd(rhId: Long?, c: CC) = Cmd(System.currentTimeMillis(), rhId, c) - fun resp(rhId: Long?, r: CR) = Resp(System.currentTimeMillis(), rhId, r) + fun resp(rhId: Long?, r: API) = Resp(System.currentTimeMillis(), rhId, r) } } @@ -6328,8 +6520,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 @@ -6394,6 +6586,16 @@ sealed class ChatError { @Serializable @SerialName("errorRemoteHost") class ChatErrorRemoteHost(val remoteHostError: RemoteHostError): ChatError() @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() @Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError() + + val resultType: String get() = when (this) { + is ChatErrorChat -> "chat" + is ChatErrorAgent -> "agent" + is ChatErrorStore -> "store" + is ChatErrorDatabase -> "database" + is ChatErrorRemoteHost -> "remoteHost" + is ChatErrorRemoteCtrl -> "remoteCtrl" + is ChatErrorInvalidJSON -> "invalid json" + } } @Serializable @@ -6421,6 +6623,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" @@ -6499,6 +6702,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() @@ -6561,63 +6765,79 @@ sealed class StoreError { val string: String get() = when (this) { is DuplicateName -> "duplicateName" - is UserNotFound -> "userNotFound" - is UserNotFoundByName -> "userNotFoundByName" - is UserNotFoundByContactId -> "userNotFoundByContactId" - is UserNotFoundByGroupId -> "userNotFoundByGroupId" - is UserNotFoundByFileId -> "userNotFoundByFileId" - is UserNotFoundByContactRequestId -> "userNotFoundByContactRequestId" - is ContactNotFound -> "contactNotFound" - is ContactNotFoundByName -> "contactNotFoundByName" - is ContactNotFoundByMemberId -> "contactNotFoundByMemberId" - is ContactNotReady -> "contactNotReady" + is UserNotFound -> "userNotFound $userId" + is UserNotFoundByName -> "userNotFoundByName $contactName" + is UserNotFoundByContactId -> "userNotFoundByContactId $contactId" + is UserNotFoundByGroupId -> "userNotFoundByGroupId $groupId" + is UserNotFoundByFileId -> "userNotFoundByFileId $fileId" + is UserNotFoundByContactRequestId -> "userNotFoundByContactRequestId $contactRequestId" + is ContactNotFound -> "contactNotFound $contactId" + is ContactNotFoundByName -> "contactNotFoundByName $contactName" + is ContactNotFoundByMemberId -> "contactNotFoundByMemberId $groupMemberId" + is ContactNotReady -> "contactNotReady $contactName" is DuplicateContactLink -> "duplicateContactLink" is UserContactLinkNotFound -> "userContactLinkNotFound" - is ContactRequestNotFound -> "contactRequestNotFound" - is ContactRequestNotFoundByName -> "contactRequestNotFoundByName" - is GroupNotFound -> "groupNotFound" - is GroupNotFoundByName -> "groupNotFoundByName" - is GroupMemberNameNotFound -> "groupMemberNameNotFound" - is GroupMemberNotFound -> "groupMemberNotFound" - is GroupMemberNotFoundByMemberId -> "groupMemberNotFoundByMemberId" - is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound" + is ContactRequestNotFound -> "contactRequestNotFound $contactRequestId" + is ContactRequestNotFoundByName -> "contactRequestNotFoundByName $contactName" + is GroupNotFound -> "groupNotFound $groupId" + is GroupNotFoundByName -> "groupNotFoundByName $groupName" + is GroupMemberNameNotFound -> "groupMemberNameNotFound $groupId $groupMemberName" + is GroupMemberNotFound -> "groupMemberNotFound $groupMemberId" + is GroupMemberNotFoundByMemberId -> "groupMemberNotFoundByMemberId $memberId" + is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound $contactId" is GroupWithoutUser -> "groupWithoutUser" is DuplicateGroupMember -> "duplicateGroupMember" is GroupAlreadyJoined -> "groupAlreadyJoined" is GroupInvitationNotFound -> "groupInvitationNotFound" - is SndFileNotFound -> "sndFileNotFound" - is SndFileInvalid -> "sndFileInvalid" - is RcvFileNotFound -> "rcvFileNotFound" - is RcvFileDescrNotFound -> "rcvFileDescrNotFound" - is FileNotFound -> "fileNotFound" - is RcvFileInvalid -> "rcvFileInvalid" + is NoteFolderAlreadyExists -> "noteFolderAlreadyExists $noteFolderId" + is NoteFolderNotFound -> "noteFolderNotFound $noteFolderId" + is UserNoteFolderNotFound -> "userNoteFolderNotFound" + is SndFileNotFound -> "sndFileNotFound $fileId" + is SndFileInvalid -> "sndFileInvalid $fileId" + is RcvFileNotFound -> "rcvFileNotFound $fileId" + is RcvFileDescrNotFound -> "rcvFileDescrNotFound $fileId" + is FileNotFound -> "fileNotFound $fileId" + is RcvFileInvalid -> "rcvFileInvalid $fileId" is RcvFileInvalidDescrPart -> "rcvFileInvalidDescrPart" - is SharedMsgIdNotFoundByFileId -> "sharedMsgIdNotFoundByFileId" - is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId" - is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP" - is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP" - is ExtraFileDescrNotFoundXFTP -> "extraFileDescrNotFoundXFTP" - is ConnectionNotFound -> "connectionNotFound" - is ConnectionNotFoundById -> "connectionNotFoundById" - is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId" - is PendingConnectionNotFound -> "pendingConnectionNotFound" + is LocalFileNoTransfer -> "localFileNoTransfer $fileId" + is SharedMsgIdNotFoundByFileId -> "sharedMsgIdNotFoundByFileId $fileId" + is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId $sharedMsgId" + is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP $agentSndFileId" + is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP $agentRcvFileId" + is ConnectionNotFound -> "connectionNotFound $agentConnId" + is ConnectionNotFoundById -> "connectionNotFoundById $connId" + is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId $groupMemberId" + is PendingConnectionNotFound -> "pendingConnectionNotFound $connId" is IntroNotFound -> "introNotFound" is UniqueID -> "uniqueID" - is InternalError -> "internalError" - is NoMsgDelivery -> "noMsgDelivery" - is BadChatItem -> "badChatItem" - is ChatItemNotFound -> "chatItemNotFound" - is ChatItemNotFoundByText -> "chatItemNotFoundByText" - is ChatItemSharedMsgIdNotFound -> "chatItemSharedMsgIdNotFound" - is ChatItemNotFoundByFileId -> "chatItemNotFoundByFileId" - is ChatItemNotFoundByGroupId -> "chatItemNotFoundByGroupId" - is ProfileNotFound -> "profileNotFound" - is DuplicateGroupLink -> "duplicateGroupLink" - is GroupLinkNotFound -> "groupLinkNotFound" - is HostMemberIdNotFound -> "hostMemberIdNotFound" - is ContactNotFoundByFileId -> "contactNotFoundByFileId" - is NoGroupSndStatus -> "noGroupSndStatus" is LargeMsg -> "largeMsg" + is InternalError -> "internalError $message" + is DBException -> "dBException $message" + is DBBusyError -> "dBBusyError $message" + is BadChatItem -> "badChatItem $itemId" + is ChatItemNotFound -> "chatItemNotFound $itemId" + is ChatItemNotFoundByText -> "chatItemNotFoundByText $text" + is ChatItemSharedMsgIdNotFound -> "chatItemSharedMsgIdNotFound $sharedMsgId" + is ChatItemNotFoundByFileId -> "chatItemNotFoundByFileId $fileId" + is ChatItemNotFoundByContactId -> "chatItemNotFoundByContactId $contactId" + is ChatItemNotFoundByGroupId -> "chatItemNotFoundByGroupId $groupId" + is ProfileNotFound -> "profileNotFound $profileId" + is DuplicateGroupLink -> "duplicateGroupLink ${groupInfo.groupId}" + is GroupLinkNotFound -> "groupLinkNotFound ${groupInfo.groupId}" + is HostMemberIdNotFound -> "hostMemberIdNotFound $groupId" + is ContactNotFoundByFileId -> "contactNotFoundByFileId $fileId" + is NoGroupSndStatus -> "noGroupSndStatus $itemId $groupMemberId" + is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" + is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" + is RemoteHostUnknown -> "remoteHostUnknown" + is RemoteHostDuplicateCA -> "remoteHostDuplicateCA" + is RemoteCtrlNotFound -> "remoteCtrlNotFound $remoteCtrlId" + is RemoteCtrlDuplicateCA -> "remoteCtrlDuplicateCA" + is ProhibitedDeleteUser -> "prohibitedDeleteUser $userId $contactId" + is OperatorNotFound -> "operatorNotFound $serverOperatorId" + is UsageConditionsNotFound -> "usageConditionsNotFound" + is InvalidQuote -> "invalidQuote" + is InvalidMention -> "invalidMention" } @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @@ -6645,6 +6865,9 @@ sealed class StoreError { @Serializable @SerialName("duplicateGroupMember") object DuplicateGroupMember: StoreError() @Serializable @SerialName("groupAlreadyJoined") object GroupAlreadyJoined: StoreError() @Serializable @SerialName("groupInvitationNotFound") object GroupInvitationNotFound: StoreError() + @Serializable @SerialName("noteFolderAlreadyExists") class NoteFolderAlreadyExists(val noteFolderId: Long): StoreError() + @Serializable @SerialName("noteFolderNotFound") class NoteFolderNotFound(val noteFolderId: Long): StoreError() + @Serializable @SerialName("userNoteFolderNotFound") object UserNoteFolderNotFound: StoreError() @Serializable @SerialName("sndFileNotFound") class SndFileNotFound(val fileId: Long): StoreError() @Serializable @SerialName("sndFileInvalid") class SndFileInvalid(val fileId: Long): StoreError() @Serializable @SerialName("rcvFileNotFound") class RcvFileNotFound(val fileId: Long): StoreError() @@ -6652,24 +6875,27 @@ sealed class StoreError { @Serializable @SerialName("fileNotFound") class FileNotFound(val fileId: Long): StoreError() @Serializable @SerialName("rcvFileInvalid") class RcvFileInvalid(val fileId: Long): StoreError() @Serializable @SerialName("rcvFileInvalidDescrPart") object RcvFileInvalidDescrPart: StoreError() + @Serializable @SerialName("localFileNoTransfer") class LocalFileNoTransfer(val fileId: Long): StoreError() @Serializable @SerialName("sharedMsgIdNotFoundByFileId") class SharedMsgIdNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("fileIdNotFoundBySharedMsgId") class FileIdNotFoundBySharedMsgId(val sharedMsgId: String): StoreError() @Serializable @SerialName("sndFileNotFoundXFTP") class SndFileNotFoundXFTP(val agentSndFileId: String): StoreError() @Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError() - @Serializable @SerialName("extraFileDescrNotFoundXFTP") class ExtraFileDescrNotFoundXFTP(val fileId: Long): StoreError() @Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError() @Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError() @Serializable @SerialName("connectionNotFoundByMemberId") class ConnectionNotFoundByMemberId(val groupMemberId: Long): StoreError() @Serializable @SerialName("pendingConnectionNotFound") class PendingConnectionNotFound(val connId: Long): StoreError() @Serializable @SerialName("introNotFound") object IntroNotFound: StoreError() @Serializable @SerialName("uniqueID") object UniqueID: StoreError() + @Serializable @SerialName("largeMsg") object LargeMsg: StoreError() @Serializable @SerialName("internalError") class InternalError(val message: String): StoreError() - @Serializable @SerialName("noMsgDelivery") class NoMsgDelivery(val connId: Long, val agentMsgId: String): StoreError() + @Serializable @SerialName("dBException") class DBException(val message: String): StoreError() + @Serializable @SerialName("dBBusyError") class DBBusyError(val message: String): StoreError() @Serializable @SerialName("badChatItem") class BadChatItem(val itemId: Long): StoreError() @Serializable @SerialName("chatItemNotFound") class ChatItemNotFound(val itemId: Long): StoreError() @Serializable @SerialName("chatItemNotFoundByText") class ChatItemNotFoundByText(val text: String): StoreError() @Serializable @SerialName("chatItemSharedMsgIdNotFound") class ChatItemSharedMsgIdNotFound(val sharedMsgId: String): StoreError() @Serializable @SerialName("chatItemNotFoundByFileId") class ChatItemNotFoundByFileId(val fileId: Long): StoreError() + @Serializable @SerialName("chatItemNotFoundByContactId") class ChatItemNotFoundByContactId(val contactId: Long): StoreError() @Serializable @SerialName("chatItemNotFoundByGroupId") class ChatItemNotFoundByGroupId(val groupId: Long): StoreError() @Serializable @SerialName("profileNotFound") class ProfileNotFound(val profileId: Long): StoreError() @Serializable @SerialName("duplicateGroupLink") class DuplicateGroupLink(val groupInfo: GroupInfo): StoreError() @@ -6677,7 +6903,17 @@ sealed class StoreError { @Serializable @SerialName("hostMemberIdNotFound") class HostMemberIdNotFound(val groupId: Long): 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("duplicateGroupMessage") class DuplicateGroupMessage(val groupId: Long, val sharedMsgId: String, val authorGroupMemberId: Long?, val forwardedByGroupMemberId: Long?): StoreError() + @Serializable @SerialName("remoteHostNotFound") class RemoteHostNotFound(val remoteHostId: Long): StoreError() + @Serializable @SerialName("remoteHostUnknown") object RemoteHostUnknown: StoreError() + @Serializable @SerialName("remoteHostDuplicateCA") object RemoteHostDuplicateCA: StoreError() + @Serializable @SerialName("remoteCtrlNotFound") class RemoteCtrlNotFound(val remoteCtrlId: Long): StoreError() + @Serializable @SerialName("remoteCtrlDuplicateCA") class RemoteCtrlDuplicateCA: StoreError() + @Serializable @SerialName("prohibitedDeleteUser") class ProhibitedDeleteUser(val userId: Long, val contactId: Long): StoreError() + @Serializable @SerialName("operatorNotFound") class OperatorNotFound(val serverOperatorId: Long): StoreError() + @Serializable @SerialName("usageConditionsNotFound") object UsageConditionsNotFound: StoreError() + @Serializable @SerialName("invalidQuote") object InvalidQuote: StoreError() + @Serializable @SerialName("invalidMention") object InvalidMention: StoreError() } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 60a65eaac6..780f8c25b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -28,6 +28,8 @@ val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) else BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE +val databaseBackend: String = if (appPlatform == AppPlatform.ANDROID) "sqlite" else BuildConfigCommon.DATABASE_BACKEND + class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { if(size > capacity) removeFirst() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 5262714099..a9f2dcaffc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -63,7 +63,11 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() val confirm = confirmMigrations ?: if (appPreferences.developerTools.get() && appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp - var migrated: Array = chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value) + var migrated: Array = if (databaseBackend == "postgres") { + chatMigrateInit("simplex_v1", "postgresql://simplex@/simplex_v1", MigrationConfirmation.Error.value) + } else { + chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value) + } var res: DBMigrationResult = runCatching { json.decodeFromString(migrated[0] as String) }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } @@ -75,7 +79,11 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } if (rerunMigration) { chatModel.dbMigrationInProgress.value = true - migrated = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) + migrated = if (databaseBackend == "postgres") { + chatMigrateInit("simplex_v1", "postgresql://simplex@/simplex_v1", confirm.value) + } else { + chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) + } res = runCatching { json.decodeFromString(migrated[0] as String) }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index fca69d5398..19e40ab0a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -16,7 +16,7 @@ expect fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOut expect fun GrayU8.toImageBitmap(): ImageBitmap expect fun ImageBitmap.hasAlpha(): Boolean -expect fun ImageBitmap.addLogo(): ImageBitmap +expect fun ImageBitmap.addLogo(size: Float): ImageBitmap expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap expect fun isImage(uri: URI): Boolean 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 51d26f8ff2..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 @@ -39,7 +39,7 @@ abstract class NtfManager { fun notifyMessageReceived(rhId: Long?, user: UserLike, cInfo: ChatInfo, cItem: ChatItem) { if ( cItem.showNotification && - cInfo.ntfsEnabled && + cInfo.ntfsEnabled(cItem) && ( allowedToShowNotification() || chatModel.chatId.value != cInfo.id || @@ -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) } } @@ -134,7 +134,12 @@ abstract class NtfManager { } res } else { - cItem.text + val mc = cItem.content.msgContent + if (mc is MsgContent.MCReport) { + generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text }) + } else { + cItem.text + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 1dff386684..6b301b9df4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -1,8 +1,10 @@ package chat.simplex.common.platform -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import chat.simplex.common.views.chat.ComposeMessage import chat.simplex.common.views.chat.ComposeState import java.net.URI @@ -10,14 +12,15 @@ import java.net.URI expect fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + disabledText: String?, sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, - userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester? = null, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt index e6d4514875..b1965ec845 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt @@ -45,6 +45,8 @@ expect fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State? = null, additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, + maxHeight: State? = null, + containerAlignment: Alignment = Alignment.TopStart, content: LazyListScope.() -> Unit ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 67fae65897..37aa7fc1d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -10,8 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow @@ -45,16 +47,16 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState CallState.InvitationSent) break - val msg = apiResp.resp - if (apiResp.remoteHostId == call.remoteHostId && - msg is CR.ChatItemsStatusesUpdated && - msg.chatItems.any { + if (msg.rhId == call.remoteHostId && + msg is API.Result && + msg.res is CR.ChatItemsStatusesUpdated && + msg.res.chatItems.any { it.chatInfo.id == call.contact.id && it.chatItem.content is CIContent.SndCall && it.chatItem.meta.itemStatus is CIStatus.SndRcvd } ) { 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 2b3cf773cc..a8b77e8fdd 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.* import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,15 +36,15 @@ 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.model.ChatModel.withReportsChatsIfOpen 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.ChatTTLOption import chat.simplex.common.views.chatlist.updateChatSettings 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 @@ -53,6 +54,7 @@ import java.io.File @Composable fun ChatInfoView( + chatsCtx: ChatModel.ChatsContext, chatModel: ChatModel, contact: Contact, connectionStats: ConnectionStats?, @@ -74,6 +76,9 @@ fun ChatInfoView( } val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } + val chatItemTTL = remember(contact.id) { mutableStateOf(if (contact.chatItemTTL != null) ChatItemTTL.fromSeconds(contact.chatItemTTL) else null) } + val deletingItems = rememberSaveable(contact.id) { mutableStateOf(false) } + ChatInfoLayout( chat, contact, @@ -84,6 +89,16 @@ fun ChatInfoView( updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, + chatItemTTL = chatItemTTL, + setChatItemTTL = { + if (it == chatItemTTL.value) { + return@ChatInfoLayout + } + val previousChatTTL = chatItemTTL.value + chatItemTTL.value = it + + setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + }, connStats = connStats, contactNetworkStatus.value, customUserProfile, @@ -109,8 +124,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() @@ -123,8 +138,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) } } } @@ -154,8 +169,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( @@ -173,7 +188,8 @@ fun ChatInfoView( } }, close = close, - onSearchClicked = onSearchClicked + onSearchClicked = onSearchClicked, + deletingItems = deletingItems ) } } @@ -182,8 +198,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) } } } @@ -457,14 +473,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) { @@ -504,6 +520,8 @@ fun ChatInfoLayout( currentUser: User, sendReceipts: State, setSendReceipts: (SendReceipts) -> Unit, + chatItemTTL: MutableState, + setChatItemTTL: (ChatItemTTL?) -> Unit, connStats: MutableState, contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, @@ -520,7 +538,8 @@ fun ChatInfoLayout( syncContactConnectionForce: () -> Unit, verifyClicked: () -> Unit, close: () -> Unit, - onSearchClicked: () -> Unit + onSearchClicked: () -> Unit, + deletingItems: State ) { val cStats = connStats.value val scrollState = rememberScrollState() @@ -597,6 +616,12 @@ fun ChatInfoLayout( } SectionDividerSpaced(maxBottomPadding = false) + SectionView { + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + } + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + val conn = contact.activeConn if (conn != null) { SectionView { @@ -813,17 +838,18 @@ fun MuteButton( chat: Chat, contact: Contact ) { - val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + val enableNtfs = remember { mutableStateOf(contact.chatSettings.enableNtfs ) } + val nextNtfMode by remember { derivedStateOf { enableNtfs.value.nextMode(false) } } val disabled = !contact.ready || !contact.active InfoViewActionButton( modifier = modifier, - icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), - title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), + icon = painterResource(nextNtfMode.icon), + title = stringResource(nextNtfMode.text(false)), disabled = disabled, disabledLook = disabled, onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled.value, chatModel, ntfsEnabled) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNtfMode, chatModel, enableNtfs) } ) } @@ -1245,11 +1271,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))) } } } @@ -1258,8 +1284,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) } } } @@ -1308,6 +1334,83 @@ fun queueInfoText(info: Pair): String { return generalGetString(MR.strings.message_queue_info_server_info).format(json.encodeToString(qInfo), msgInfo) } +fun setChatTTLAlert( + chatsCtx: ChatModel.ChatsContext, + rhId: Long?, + chatInfo: ChatInfo, + selectedChatTTL: MutableState, + previousChatTTL: ChatItemTTL?, + progressIndicator: MutableState +) { + val defaultTTL = chatModel.chatItemTTL.value + val previouslyUsedTTL = previousChatTTL ?: defaultTTL + val newTTLToUse = selectedChatTTL.value ?: defaultTTL + + AlertManager.shared.showAlertDialog( + title = generalGetString( + if (newTTLToUse.neverExpires) { + MR.strings.disable_automatic_deletion_question + } else if (!previouslyUsedTTL.neverExpires || selectedChatTTL.value == null) { + MR.strings.change_automatic_deletion_question + } 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(chatsCtx, rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) }, + onDismiss = { selectedChatTTL.value = previousChatTTL }, + onDismissRequest = { selectedChatTTL.value = previousChatTTL }, + destructive = true, + ) +} + +private fun setChatTTL( + chatsCtx: ChatModel.ChatsContext, + rhId: Long?, + chatInfo: ChatInfo, + chatTTL: MutableState, + progressIndicator: MutableState, + previousChatTTL: ChatItemTTL? + ) { + progressIndicator.value = true + withBGApi { + try { + chatModel.controller.setChatTTL(rhId, chatInfo.chatType, chatInfo.apiId, chatTTL.value) + afterSetChatTTL(chatsCtx, rhId, chatInfo, progressIndicator) + } catch (e: Exception) { + chatTTL.value = previousChatTTL + afterSetChatTTL(chatsCtx, rhId, chatInfo, progressIndicator) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_message_deletion), e.stackTraceToString()) + } + } +} + +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, scope = null, contentTag = 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 + withContext(Dispatchers.Main) { + val oldChat = chatModel.chatsContext.getChat(chat.id) + if (oldChat != null) { + 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, + pagination = pagination, + openAroundItemId = null + ) + } catch (e: Exception) { + Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}") + } finally { + progressIndicator.value = false + } +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -1322,6 +1425,8 @@ fun PreviewChatInfoLayout() { User.sampleData, sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, setSendReceipts = {}, + chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, + setChatItemTTL = {}, localAlias = "", connectionCode = "123", developerTools = false, @@ -1338,7 +1443,8 @@ fun PreviewChatInfoLayout() { syncContactConnectionForce = {}, verifyClicked = {}, close = {}, - onSearchClicked = {} + onSearchClicked = {}, + deletingItems = remember { mutableStateOf(false) } ) } } 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 e62235bd7c..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 @@ -42,18 +42,20 @@ sealed class CIInfoTab { } @Composable -fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { +fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean, chatInfo: ChatInfo?) { val sent = ci.chatDir.sent val appColors = MaterialTheme.appColors val uriHandler = LocalUriHandler.current val selection = remember { mutableStateOf(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState) { + fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( text, if (text.isEmpty()) emptyList() else formattedText, sender = sender, + mentions = mentions, + userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, @@ -80,7 +82,12 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu) + TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } + ) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { @@ -202,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 6419aa884d..07cdc065a7 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,61 +2,74 @@ 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 +import kotlinx.datetime.Instant 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.groupScopeInfo?.toChatScope(), 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.groupScopeInfo?.toChatScope(), 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 - if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) + /** 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(chatsCtx, chat, navInfo, pagination, openAroundItemId, visibleItemIndexesNonReversed) +} - val chatState = chatModel.chatStateForContent(contentTag) +suspend fun processLoadedChat( + chatsCtx: ChatModel.ChatsContext, + chat: Chat, + navInfo: NavigationInfo, + pagination: ChatPagination, + openAroundItemId: Long?, + visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } +) { + 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.secondaryContextFilter == null) { // update main chats, not content tagged - withChats { - if (getChat(chat.id) == null) { - addChat(chat) + withContext(Dispatchers.Main) { + val oldChat = chatModel.chatsContext.getChat(chat.id) + if (oldChat == null) { + chatModel.chatsContext.addChat(chat) } else { - updateChatInfo(chat.remoteHostId, chat.chatInfo) - updateChatStats(chat.remoteHostId, chat.id, chat.chatStats) + chatModel.chatsContext.updateChatInfo(chat.remoteHostId, chat.chatInfo) + // unreadChat is currently not actual in getChat query (always false) + chatModel.chatsContext.updateChatStats(chat.remoteHostId, chat.id, chat.chatStats.copy(unreadChat = oldChat.chatStats.unreadChat)) } } } - withChats(contentTag) { - chatItemStatuses.clear() - chatItems.replaceAll(chat.chatItems) - chatModel.chatId.value = chat.chatInfo.id + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(chat.chatItems) + chatModel.chatId.value = chat.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { unreadAfterItemId.value = chat.chatItems.last().id @@ -70,7 +83,7 @@ suspend fun apiLoadMessages( is ChatPagination.Before -> { newItems.addAll(oldItems) val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } - if (indexInCurrentItems == -1) return@coroutineScope + if (indexInCurrentItems == -1) return val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( @@ -78,8 +91,8 @@ suspend fun apiLoadMessages( ) 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) } @@ -87,7 +100,7 @@ suspend fun apiLoadMessages( is ChatPagination.After -> { newItems.addAll(oldItems) val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } - if (indexInCurrentItems == -1) return@coroutineScope + if (indexInCurrentItems == -1) return val mappedItems = mapItemsToIds(chat.chatItems) val newIds = mappedItems.first @@ -97,8 +110,8 @@ suspend fun apiLoadMessages( 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 @@ -108,27 +121,43 @@ suspend fun apiLoadMessages( } } is ChatPagination.Around -> { - newItems.addAll(oldItems) - val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) - // currently, items will always be added on top, which is index 0 - newItems.addAll(0, chat.chatItems) - withChats(contentTag) { - chatItems.replaceAll(newItems) - splits.value = listOf(chat.chatItems.last().id) + newSplits + val newSplits: ArrayList = if (openAroundItemId == null) { + newItems.addAll(oldItems) + ArrayList(removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)) + } else { + arrayListOf() + } + val (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.lastOrNull(), to = newItems, newSplits.toSet()) + //indexToInsertAroundTest() + newItems.addAll(itemIndex, chat.chatItems) + newSplits.add(splitIndex, chat.chatItems.last().id) + + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) + splits.value = newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal unreadTotal.value = chat.chatStats.unreadCount unreadAfter.value = navInfo.afterUnread - // no need to set it, count will be wrong - // unreadAfterNewestLoaded.value = navInfo.afterUnread + + if (openAroundItemId != null) { + unreadAfterNewestLoaded.value = navInfo.afterUnread + chatModel.openAroundItemId.value = openAroundItemId + chatModel.chatId.value = chat.id + } else { + // no need to set it, count will be wrong + // unreadAfterNewestLoaded.value = navInfo.afterUnread + } } } is ChatPagination.Last -> { newItems.addAll(oldItems) + 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 } } @@ -214,7 +243,15 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination( val indexInSplitRanges = splits.value.indexOf(paginationChatItemId) // Currently, it should always load from split range val loadingFromSplitRange = indexInSplitRanges != -1 - val splitsToMerge = if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) else ArrayList() + val topSplits: List + val splitsToMerge: ArrayList + if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) { + splitsToMerge = ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) + topSplits = splits.value.take(indexInSplitRanges + 1) + } else { + splitsToMerge = ArrayList() + topSplits = emptyList() + } newItems.removeAll { val duplicate = newIds.contains(it.id) if (loadingFromSplitRange && duplicate) { @@ -233,8 +270,8 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination( } var newSplits: List = emptyList() if (firstItemIdBelowAllSplits != null) { - // no splits anymore, all were merged with bottom items - newSplits = emptyList() + // no splits below anymore, all were merged with bottom items + newSplits = topSplits } else { if (splitsToRemove.isNotEmpty()) { val new = ArrayList(splits.value) @@ -297,6 +334,31 @@ private fun removeDuplicatesAndUpperSplits( return newSplits } +private fun removeDuplicatesAndUnusedSplits( + newItems: SnapshotStateList, + chat: Chat, + splits: List +): List { + if (splits.isEmpty()) { + removeDuplicates(newItems, chat) + return splits + } + + val newSplits = splits.toMutableList() + val (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll { + val duplicate = newIds.contains(it.id) + if (duplicate) { + val firstIndex = newSplits.indexOf(it.id) + if (firstIndex != -1) { + newSplits.removeAt(firstIndex) + } + } + duplicate + } + return newSplits +} + // ids, number of unread items private fun mapItemsToIds(items: List): Pair, Int> { var unreadInLoaded = 0 @@ -317,3 +379,141 @@ private fun removeDuplicates(newItems: SnapshotStateList, chat: Chat) val (newIds, _) = mapItemsToIds(chat.chatItems) newItems.removeAll { newIds.contains(it.id) } } + +private data class SameTimeItem(val index: Int, val item: ChatItem) + +// return (item index, split index) +private fun indexToInsertAround(chatType: ChatType, lastNew: ChatItem?, to: List, splits: Set): Pair { + if (to.size <= 0 || lastNew == null) { + return 0 to 0 + } + // group sorting: item_ts, item_id + // everything else: created_at, item_id + val compareByTimeTs = chatType == ChatType.Group + // in case several items have the same time as another item in the `to` array + var sameTime: ArrayList = arrayListOf() + + // trying to find new split index for item looks difficult but allows to not use one more loop. + // The idea is to memorize how many splits were till any index (map number of splits until index) + // and use resulting itemIndex to decide new split index position. + // Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`. + val splitsTillIndex: ArrayList = arrayListOf() + var splitsPerPrevIndex = 0 + + for (i in to.indices) { + val item = to[i] + + splitsPerPrevIndex = if (splits.contains(item.id)) splitsPerPrevIndex + 1 else splitsPerPrevIndex + splitsTillIndex.add(splitsPerPrevIndex) + val itemIsNewer = (if (compareByTimeTs) item.meta.itemTs > lastNew.meta.itemTs else item.meta.createdAt > lastNew.meta.createdAt) + if (itemIsNewer || i + 1 == to.size) { + val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt + if (same) { + sameTime.add(SameTimeItem(i, item)) + } + // time to stop the loop. Item is newer, or it's the last item in `to` array, taking previous items and checking position inside them + val itemIndex: Int + val first = if (sameTime.size > 1) sameTime.sortedWith { prev, next -> prev.item.meta.itemId.compareTo(next.item.id) }.firstOrNull { same -> same.item.id > lastNew.id } else null + if (sameTime.size > 1 && first != null) { + itemIndex = first.index + } else if (sameTime.size == 1) { + itemIndex = if (sameTime[0].item.id > lastNew.id) sameTime[0].index else sameTime[0].index + 1 + } else { + itemIndex = if (itemIsNewer) i else i + 1 + } + val splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.size - 1)] + val prevItemSplitIndex = if (itemIndex == 0) 0 else splitsTillIndex[min(itemIndex - 1, splitsTillIndex.size - 1)] + return Pair(itemIndex, if (splitIndex == prevItemSplitIndex) splitIndex else prevItemSplitIndex) + } + val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt + if (same) { + sameTime.add(SameTimeItem(index = i, item = item)) + } else { + sameTime = arrayListOf() + } + } + // shouldn't be here + return Pair(to.size, splits.size) +} + +private fun indexToInsertAroundTest() { + fun assert(one: Pair, two: Pair) { + if (one != two) { + throw Exception("$one != $two") + } + } + + val itemsToInsert = listOf(ChatItem.getSampleData(3, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 3), "")) + val items1 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 0), ""), + ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 1), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 2), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items1, setOf(1)), Pair(3, 1)) + + val items2 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(1), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items2, setOf(2)), Pair(3, 1)) + + val items3 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items3, setOf(1)), Pair(3, 1)) + + val items4 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items4, setOf(4)), Pair(1, 0)) + + val items5 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items5, setOf(2)), Pair(2, 1)) + + val items6 = listOf( + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items6, setOf(5)), Pair(0, 0)) + + val items7 = listOf( + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, null, to = items7, setOf(6)), Pair(0, 0)) + + val items8 = listOf( + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items8, setOf(2)), Pair(0, 0)) + + val items9 = listOf( + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items9, setOf(5)), Pair(1, 0)) + + val items10 = listOf( + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items10, setOf(4)), Pair(0, 0)) + + val items11: List = listOf() + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items11, emptySet()), Pair(0, 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 7713a2399f..2ca0dcc35d 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 @@ -13,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.layout.layoutId @@ -31,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.* @@ -58,29 +57,34 @@ 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, - reportsView: Boolean, scrollToItemId: MutableState = remember { mutableStateOf(null) }, onComposed: suspend (chatId: String) -> Unit ) { val showSearch = rememberSaveable { mutableStateOf(false) } + val chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } - val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } } - val activeChatStats = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats } } + val activeChatInfo = remember { derivedStateOf { + var chatInfo = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo + if ( + chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + && chatInfo is ChatInfo.Group + ) { + val scopeInfo = chatsCtx.secondaryContextFilter.groupScopeInfo + chatInfo = chatInfo.copy(groupChatScope = scopeInfo) + } + chatInfo + } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value - if (chatInfo == null || user == null) { + if (chat == null || chatInfo == null || user == null) { LaunchedEffect(Unit) { chatModel.chatId.value = null ModalManager.end.closeModals() } } else { - val groupReports = remember { derivedStateOf { - val reportsCount = if (activeChatInfo.value is ChatInfo.Group) activeChatStats.value?.reportsCount ?: 0 else 0 - GroupReports(reportsCount, reportsView) } - } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(groupReports.value.contentTag).value.asReversed() } } val searchText = rememberSaveable { mutableStateOf("") } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { @@ -106,7 +110,7 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> - if (!groupReports.value.reportsView) { + if (chatsCtx.secondaryContextFilter == null) { markUnreadChatAsRead(chatId) } showSearch.value = false @@ -115,19 +119,31 @@ fun ChatView( } } } + if (chatsCtx.secondaryContextFilter == null && chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberPending) { + LaunchedEffect(Unit) { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(chatInfo.groupInfo, groupChatScope = scopeInfo) + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + } val view = LocalMultiplatformView() val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chatsForContent(if (reportsView) MsgContentTag.Report else null).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 reportsCount = reportsCount(chatInfo.id) val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), - LocalContentTag provides groupReports.value.contentTag ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -135,23 +151,27 @@ fun ChatView( val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } + SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> - if (searchText.value == value) return@onSearchValueChanged - val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged - if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged + 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 && chatsCtx.secondaryContextFilter == null + val c = chatModel.getChat(chatInfo.id) + if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { - apiFindMessages(c, value, groupReports.value.toContentTag()) + apiFindMessages(chatsCtx, c, value) searchText.value = value } } ChatLayout( + chatsCtx = chatsCtx, remoteHostId = remoteHostId, chatInfo = activeChatInfo, - reversedChatItems = reversedChatItems, unreadCount, composeState, - composeView = { + composeView = { focusRequester -> if (selectedChatItems.value == null) { Column( Modifier.fillMaxWidth(), @@ -171,13 +191,14 @@ fun ChatView( ) } ComposeView( - chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, - showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + rhId = remoteHostId.value, chatModel, chatsCtx, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }, + focusRequester = focusRequester ) } } else { - SelectedItemsBottomToolbar( - reversedChatItems = reversedChatItems, + SelectedItemsButtonsToolbar( + chatsCtx = chatsCtx, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -200,6 +221,7 @@ fun ChatView( ) } }, + archiveItems = { archiveItems(chatRh, chatInfo, selectedChatItems) }, moderateItems = { if (chatInfo is ChatInfo.Group) { val itemIds = selectedChatItems.value @@ -222,6 +244,7 @@ fun ChatView( rh = chatRh, fromChatType = chatInfo.chatType, fromChatId = chatInfo.apiId, + fromScope = chatInfo.groupChatScope(), chatItemIds = chatItemIds ) @@ -238,7 +261,6 @@ fun ChatView( ) } }, - groupReports, scrollToItemId, attachmentOption, attachmentBottomSheetState, @@ -252,6 +274,7 @@ fun ChatView( chatModel.chatId.value = null chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -264,7 +287,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 @@ -274,40 +297,52 @@ fun ChatView( } if (!isActive) return@launch - ModalManager.end.showModalCloseable(true) { close -> - val chatInfo = remember { activeChatInfo }.value - if (chatInfo is ChatInfo.Direct) { - var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } - var code: String? by remember { mutableStateOf(preloadedCode) } - KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { - contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) - preloadedContactInfo = contactInfo - code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second - preloadedCode = code + val selectedItems: MutableState?> = mutableStateOf(null) + ModalManager.end.showCustomModal { close -> + val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) } + ModalView(close, appBar = appBar.value) { + val chatInfo = remember { activeChatInfo }.value + if (chatInfo is ChatInfo.Direct) { + var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } + var code: String? by remember { mutableStateOf(preloadedCode) } + KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { + contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) + preloadedContactInfo = contactInfo + code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second + preloadedCode = code + } + 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) } + KeyChangeEffect(chatInfo.id) { + setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) + link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) + preloadedLink = link + } + GroupChatInfoView(chatsCtx, chatRh, chatInfo.id, link?.first, link?.second, selectedItems, appBar, scrollToItemId, { + link = it + preloadedLink = it + }, close, { showSearch.value = true }) + } else { + LaunchedEffect(Unit) { + close() + } } - ChatInfoView(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) } - KeyChangeEffect(chatInfo.id) { - setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) - link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) - preloadedLink = link - } - GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, scrollToItemId, { - link = it - preloadedLink = it - }, close, { showSearch.value = true }) - } else { LaunchedEffect(Unit) { - close() + snapshotFlow { activeChatInfo.value?.id } + .drop(1) + .collect { + appBar.value = null + selectedItems.value = null + } } } } } }, - showGroupReports = { + showReports = { val info = activeChatInfo.value ?: return@ChatLayout if (ModalManager.end.hasModalsOpen()) { ModalManager.end.closeModals() @@ -318,6 +353,38 @@ fun ChatView( showGroupReportsView(staleChatId, scrollToItemId, info) } }, + showSupportChats = { + val info = activeChatInfo.value ?: return@ChatLayout + if (ModalManager.end.hasModalsOpen()) { + ModalManager.end.closeModals() + return@ChatLayout + } + hideKeyboard(view) + scope.launch { + if (info is ChatInfo.Group && info.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ModalManager.end.showCustomModal { close -> + MemberSupportView( + chatRh, + chat, + info.groupInfo, + scrollToItemId, + close + ) + } + } else if (info is ChatInfo.Group) { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(info.groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + } + } + }, showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) groupMembersJob.cancel() @@ -333,12 +400,12 @@ fun ChatView( setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - if (!groupReports.value.reportsView) { + if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, close, close) } } } @@ -347,12 +414,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, groupReports.value.toContentTag(), pagination, searchText.value, visibleItemIndexes) + apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, searchText.value, null, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = reversedChatItems.value.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 @@ -369,6 +436,7 @@ fun ChatView( chatRh, type = chatInfo.chatType, id = chatInfo.apiId, + scope = chatInfo.groupChatScope(), itemIds = listOf(itemId), mode = mode ) @@ -377,30 +445,30 @@ 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) } + chatModel.chatsContext.updateChatInfo(chatRh, deleted.deletedChatItem.chatInfo) } - withReportsChatsIfOpen { - if (deletedChatItem.isReport) { - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) - } + withContext(Dispatchers.Main) { + if (toChatItem != null) { + chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, deletedChatItem) } } } } }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, + archiveReports = { itemIds, forAll -> archiveReports(chatRh, chatInfo, itemIds, forAll) }, receiveFile = { fileId -> withBGApi { chatModel.controller.receiveFile(chatRh, user, fileId) } }, @@ -450,8 +518,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) } } } @@ -462,8 +530,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) } } } @@ -473,8 +541,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) } } } @@ -483,8 +551,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) } } } @@ -501,25 +569,24 @@ fun ChatView( rh = chatRh, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), itemId = cItem.id, add = add, reaction = reaction ) if (updatedCI != null) { - withChats { - updateChatItem(cInfo, updatedCI) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(cInfo, updatedCI) } - withReportsChatsIfOpen { - if (cItem.isReport) { - updateChatItem(cInfo, updatedCI) - } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, updatedCI) } } } }, showItemDetails = { cInfo, cItem -> suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { - val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) + val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cInfo.groupChatScope(), cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) @@ -531,7 +598,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 = { @@ -540,14 +607,14 @@ fun ChatView( } }) { close -> var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) LaunchedEffect(cItem.id) { withContext(Dispatchers.Default) { - for (apiResp in controller.messagesChannel) { - val msg = apiResp.resp - if (apiResp.remoteHostId == chatRh && - msg is CR.ChatItemsStatusesUpdated && - msg.chatItems.any { it.chatItem.id == cItem.id } + for (msg in controller.messagesChannel) { + if (msg.rhId == chatRh && + msg is API.Result && + msg.res is CR.ChatItemsStatusesUpdated && + msg.res.chatItems.any { it.chatItem.id == cItem.id } ) { ciInfo = loadChatItemInfo() ?: return@withContext initialCiInfo = ciInfo @@ -565,45 +632,48 @@ 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( + val updatedChatInfo = chatModel.controller.apiChatItemsRead( chatRh, chatInfo.chatType, chatInfo.apiId, + chatInfo.groupChatScope(), itemsIds ) + if (updatedChatInfo != null) { + chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + } } - 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, chatInfo.chatType, - chatInfo.apiId + chatInfo.apiId, + chatInfo.groupChatScope() ) } - withReportsChatsIfOpen { - markChatItemsRead(chatRh, chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.markChatItemsRead(chatRh, chatInfo.id) } } }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, onSearchValueChanged = onSearchValueChanged, + closeSearch = { + showSearch.value = false + searchText.value = "" + }, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), @@ -614,13 +684,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() } } } @@ -632,8 +702,8 @@ fun ChatView( LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - withChats { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() } } } @@ -658,13 +728,12 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) @Composable fun ChatLayout( + chatsCtx: ChatModel.ChatsContext, remoteHostId: State, chatInfo: State, - reversedChatItems: State>, unreadCount: State, composeState: MutableState, - composeView: (@Composable () -> Unit), - groupReports: State, + composeView: (@Composable (FocusRequester?) -> Unit), scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, @@ -674,11 +743,13 @@ fun ChatLayout( selectedChatItems: MutableState?>, back: () -> Unit, info: () -> Unit, - showGroupReports: () -> Unit, + showReports: () -> Unit, + showSupportChats: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -700,8 +771,9 @@ fun ChatLayout( openGroupLink: (GroupInfo) -> Unit, markItemsRead: (List) -> Unit, markChatRead: () -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, + closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean, @@ -713,7 +785,7 @@ fun ChatLayout( Modifier .fillMaxWidth() .desktopOnExternalDrag( - enabled = remember(attachmentDisabled.value, chatInfo.value?.userCanSend) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.userCanSend == true) }.value, + enabled = remember(attachmentDisabled.value, chatInfo.value?.sendMsgEnabled) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.sendMsgEnabled == true) }.value, onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) }, onImage = { file -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(file.toURI()), null) } }, onText = { @@ -735,29 +807,45 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, !groupReports.value.reportsView)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chatInfo != null) { - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, reversedChatItems, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, groupReports, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + 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, - setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, + setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, ) } + if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { + Column( + Modifier + .align(Alignment.BottomStart) + .padding(bottom = composeViewHeight.value) + ) { + GroupMentions( + chatsCtx = chatsCtx, + rhId = remoteHostId, + composeState = composeState, + composeViewFocusRequester = composeViewFocusRequester, + chatInfo = chatInfo, + ) + } + } } } - if (groupReports.value.reportsView) { + if (chatsCtx.contentTag == MsgContentTag.Report) { Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -767,8 +855,8 @@ fun ChatLayout( ) { AnimatedVisibility(selectedChatItems.value != null) { if (chatInfo != null) { - SelectedItemsBottomToolbar( - reversedChatItems = reversedChatItems, + SelectedItemsButtonsToolbar( + chatsCtx = chatsCtx, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { _ -> @@ -782,6 +870,7 @@ fun ChatLayout( }) } }, + archiveItems = { archiveItems(remoteHostId, chatInfo, selectedChatItems) }, moderateItems = {}, forwardItems = {} ) @@ -801,45 +890,81 @@ fun ChatLayout( .navigationBarsPadding() .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) ) { - composeView() + composeView(composeViewFocusRequester) } } } + val reportsCount = reportsCount(chatInfo?.id) + val supportUnreadCount = supportUnreadCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (groupReports.value.showBar) { - ReportedCountToolbar(groupReports, withStatusBar = true, showGroupReports) + if ( + chatInfo is ChatInfo.Group + && chatsCtx.secondaryContextFilter == null + && (reportsCount > 0 || supportUnreadCount > 0) + ) { + SupportChatsCountToolbar(chatInfo, reportsCount, supportUnreadCount, withStatusBar = true, showReports, showSupportChats) } else { StatusBarBackground() } } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - if (groupReports.value.reportsView) { - if (oneHandUI.value) { - StatusBarBackground() - } - Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - Box { - if (selectedChatItems.value == null) { - GroupReportsAppBar(groupReports, { ModalManager.end.closeModal() }, onSearchValueChanged) - } else { - SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value) - } - } - } - } else { - Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - Box { - if (selectedChatItems.value == null) { - if (chatInfo != null) { - ChatInfoToolbar(chatInfo, groupReports, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + when (chatsCtx.secondaryContextFilter) { + is SecondaryContextFilter.GroupChatScopeContext -> { + when (chatsCtx.secondaryContextFilter.groupScopeInfo) { + is GroupChatScopeInfo.MemberSupport -> { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + MemberSupportChatAppBar(chatsCtx, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) + } + } } - } else { - SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (groupReports.value.showBar && (!oneHandUI.value || !chatBottomBar.value)) { - ReportedCountToolbar(groupReports, withStatusBar = false, showGroupReports) + } + is SecondaryContextFilter.MsgContentTagContext -> { + when (chatsCtx.secondaryContextFilter.contentTag) { + MsgContentTag.Report -> { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + GroupReportsAppBar(chatsCtx, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) + } + } + } + } + else -> TODO() + } + } + null -> { + Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + if (chatInfo != null) { + ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + } + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) + } + } + if ( + chatInfo is ChatInfo.Group + && (reportsCount > 0 || supportUnreadCount > 0) + && (!oneHandUI.value || !chatBottomBar.value) + ) { + SupportChatsCountToolbar(chatInfo, reportsCount, supportUnreadCount, withStatusBar = false, showReports, showSupportChats) + } } } } @@ -850,15 +975,15 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, - groupReports: State, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, openGroupLink: (GroupInfo) -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, showSearch: MutableState ) { @@ -873,7 +998,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid && !groupReports.value.reportsView) { + if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -977,18 +1102,20 @@ fun BoxScope.ChatInfoToolbar( } } - if ((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) { - val ntfsEnabled = remember { mutableStateOf(chatInfo.ntfsEnabled) } + val enableNtfs = chatInfo.chatSettings?.enableNtfs + if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { + val ntfMode = remember { mutableStateOf(enableNtfs) } + val nextNtfMode by remember { derivedStateOf { ntfMode.value.nextMode(chatInfo.hasMentions) } } menuItems.add { ItemAction( - if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + stringResource(nextNtfMode.text(chatInfo.hasMentions)), + painterResource(nextNtfMode.icon), onClick = { showMenu.value = false // Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu scope.launch { delay(200) - changeNtfsState(!ntfsEnabled.value, ntfsEnabled) + changeNtfsState(nextNtfMode, ntfMode) } } ) @@ -1068,34 +1195,79 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo } @Composable -private fun ReportedCountToolbar( - groupReports: State, +private fun SupportChatsCountToolbar( + chatInfo: ChatInfo, + reportsCount: Int, + supportUnreadCount: Int, withStatusBar: Boolean, - showGroupReports: () -> Unit + showReports: () -> Unit, + showSupportChats: () -> Unit ) { Box { val statusBarPadding = if (withStatusBar) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else 0.dp Row( Modifier - .fillMaxWidth() - .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) - .background(MaterialTheme.colors.background) - .clickable(onClick = showGroupReports) - .padding(top = statusBarPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, ) { - Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) - Spacer(Modifier.width(4.dp)) - val reports = groupReports.value.reportsCount - Text( - if (reports == 1) { - stringResource(MR.strings.group_reports_active_one) - } else { - stringResource(MR.strings.group_reports_active).format(reports) - }, - style = MaterialTheme.typography.button - ) + if ( + chatInfo is ChatInfo.Group + && chatInfo.groupInfo.canModerate + && reportsCount > 0 + ) { + Row( + Modifier + .fillMaxWidth() + .weight(1F) + .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) + .background(MaterialTheme.colors.background) + .clickable(onClick = showReports) + .padding(top = statusBarPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) + Spacer(Modifier.width(4.dp)) + Text( + if (reportsCount == 1) { + stringResource(MR.strings.group_reports_active_one) + } else { + stringResource(MR.strings.group_reports_active).format(reportsCount) + }, + style = MaterialTheme.typography.button + ) + } + } + + if (supportUnreadCount > 0) { + Row( + Modifier + .fillMaxWidth() + .weight(1F) + .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) + .background(MaterialTheme.colors.background) + .clickable(onClick = showSupportChats) + .padding(top = statusBarPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(4.dp)) + Text( + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) { + if (appPlatform.isAndroid) + stringResource(MR.strings.group_new_support_chats_short).format(supportUnreadCount) + else if (supportUnreadCount == 1) + stringResource(MR.strings.group_new_support_chat_one) + else + stringResource(MR.strings.group_new_support_chats).format(supportUnreadCount) + } else { + stringResource(MR.strings.group_new_support_messages).format(supportUnreadCount) + }, + style = MaterialTheme.typography.button + ) + } + } } Divider(Modifier.align(Alignment.BottomStart)) } @@ -1106,21 +1278,20 @@ private fun ContactVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } -/** Saves current scroll position when [GroupReports] are open and user opens [ChatItemInfoView], for example, and goes back */ +/** Saves current scroll position when group reports are open and user opens [ChatItemInfoView], for example, and goes back */ private var reportsListState: LazyListState? = null @Composable fun BoxScope.ChatItemsList( + chatsCtx: ChatModel.ChatsContext, remoteHostId: Long?, chatInfo: ChatInfo, - reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeViewHeight: State, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, - groupReports: State, scrollToItemId: MutableState, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, @@ -1128,6 +1299,7 @@ fun BoxScope.ChatItemsList( loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -1145,33 +1317,80 @@ fun BoxScope.ChatItemsList( showItemDetails: (ChatInfo, ChatItem) -> Unit, markItemsRead: (List) -> Unit, markChatRead: () -> Unit, + closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean ) { + val loadingTopItems = remember { mutableStateOf(false) } + val loadingBottomItems = remember { mutableStateOf(false) } + // just for changing local var here based on request + val loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit = { chatId, pagination, visibleItemIndexesNonReversed -> + val loadingSide = when (pagination) { + is ChatPagination.Before -> loadingTopItems + is ChatPagination.Last -> loadingBottomItems + is ChatPagination.After, is ChatPagination.Around, is ChatPagination.Initial -> null + } + loadingSide?.value = true + try { + loadMessages(chatId, pagination, visibleItemIndexesNonReversed) + } finally { + loadingSide?.value = false + } + } val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } + val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatStateForContent(groupReports.value.contentTag)) } } - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) + // 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(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) + } + } + val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } + val reportsCount = reportsCount(chatInfo.id) + val supportUnreadCount = supportUnreadCount(chatInfo.id) + val topPaddingToContent = topPaddingToContent( + chatView = chatsCtx.secondaryContextFilter == null, + additionalTopBar = chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 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 * 2).roundToPx() } + with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } ) - val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { - val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + 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 + } + hoveredItemId.value = null if (reportsState != null) { reportsListState = null reportsState - } else if (index <= 0) { + } else if (index <= 0 || !searchValueIsEmpty.value) { LazyListState(0, 0) } else { LazyListState(index + 1, -maxHeightForList.value) } }) - SaveReportsStateOnDispose(groupReports, listState) + SaveReportsStateOnDispose(chatsCtx, listState) val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } @@ -1180,305 +1399,298 @@ fun BoxScope.ChatItemsList( if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) } - if (!loadingMoreItems.value) { - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), reversedChatItems, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> - if (loadingMoreItems.value) return@PreloadItems false + 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 { - loadingMoreItems.value = true loadMessages(chatId, pagination) { visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } finally { loadingMoreItems.value = false } - true } + true } - val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) val chatInfoUpdated = rememberUpdatedState(chatInfo) - val highlightedItems = remember { mutableStateOf(setOf()) } 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 (groupReports.value.reportsView) 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, groupReports.value.contentTag) } - if (!groupReports.value.reportsView) { - LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { - if (appPlatform.isAndroid) { - ModalManager.end.closeModals() + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, scrollToItemId) } + if (chatsCtx.secondaryContextFilter == null) { + LaunchedEffect(Unit) { + snapshotFlow { scrollToItemId.value }.filterNotNull().collect { + if (appPlatform.isAndroid) { + ModalManager.end.closeModals() + } + scrollToItem(it) + scrollToItemId.value = null } - scrollToItem(it) - scrollToItemId.value = null } } } - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo, groupReports) SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) DisposableEffectOnGone( - always = { - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) - }, whenGone = { VideoPlayerHolder.releaseAll() - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) } ) - @Composable - fun ChatViewListItem( - itemAtZeroIndexInWholeList: Boolean, - range: State, - showAvatar: Boolean, - cItem: ChatItem, - itemSeparation: ItemSeparation, - previousItemSeparationLargeGap: Boolean, - revealed: State, - reveal: (Boolean) -> Unit + @Composable + fun ChatViewListItem( + itemAtZeroIndexInWholeList: Boolean, + range: State, + showAvatar: Boolean, + cItem: ChatItem, + itemSeparation: ItemSeparation, + previousItemSeparationLargeGap: Boolean, + revealed: State, + reveal: (Boolean) -> Unit + ) { + val itemScope = rememberCoroutineScope() + CompositionLocalProvider( + // Makes horizontal and vertical scrolling to coexist nicely. + // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view + LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() ) { - val itemScope = rememberCoroutineScope() - CompositionLocalProvider( - // Makes horizontal and vertical scrolling to coexist nicely. - // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view - LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() - ) { - val provider = { - providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> + val provider = { + providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> + itemScope.launch { + listState.value.scrollToItem( + min(reversedChatItems.value.lastIndex, indexInReversed + 1), + -maxHeight.value + ) + } + } + } + + @Composable + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { + tryOrShowError("${cItem.id}ChatItem", error = { + CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) + }) { + val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } + LaunchedEffect(Unit) { + snapshotFlow { highlighted.value } + .distinctUntilChanged() + .filter { it } + .collect { + delay(500) + highlightedItems.value = setOf() + } + } + 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, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + } + } + + @Composable + fun ChatItemView(cItem: ChatItem, range: State, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean) { + val dismissState = rememberDismissState(initialValue = DismissValue.Default) { + if (it == DismissValue.DismissedToStart) { itemScope.launch { - listState.value.scrollToItem( - min(reversedChatItems.value.lastIndex, indexInReversed + 1), - -maxHeight.value - ) - } - } - } - - @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { - tryOrShowError("${cItem.id}ChatItem", error = { - CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) - }) { - val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } - LaunchedEffect(Unit) { - snapshotFlow { highlighted.value } - .distinctUntilChanged() - .filter { it } - .collect { - delay(500) - highlightedItems.value = setOf() - } - } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, 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) - } - } - - @Composable - fun ChatItemView(cItem: ChatItem, range: State, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean) { - val dismissState = rememberDismissState(initialValue = DismissValue.Default) { - if (it == DismissValue.DismissedToStart) { - itemScope.launch { - if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } + if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } } } - false } - val swipeableModifier = SwipeToDismissModifier( - state = dismissState, - directions = setOf(DismissDirection.EndToStart), - swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, - ) - val sent = cItem.chatDir.sent + false + } + val swipeableModifier = SwipeToDismissModifier( + state = dismissState, + directions = setOf(DismissDirection.EndToStart), + swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, + ) + val sent = cItem.chatDir.sent - @Composable - fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { - Box( - modifier = modifier.padding( - bottom = if (itemSeparation.largeGap) { - if (itemAtZeroIndexInWholeList) { - 8.dp - } else { - 4.dp - } - } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp - ), - contentAlignment = Alignment.CenterStart - ) { - content() - } + @Composable + fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { + Box( + modifier = modifier.padding( + bottom = if (itemSeparation.largeGap) { + if (itemAtZeroIndexInWholeList) { + 8.dp + } else { + 4.dp + } + } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp + ), + contentAlignment = Alignment.CenterStart + ) { + content() } + } - @Composable - fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + @Composable + fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp { + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp) - } + return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp) + } + + Box { + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null + val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf + val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) + val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } + if (chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { + if (showAvatar) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .fillMaxWidth() + .then(swipeableModifier), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + @Composable + fun MemberNameAndRole(range: State) { + Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + val member = cItem.chatDir.groupMember + val rangeValue = range.value + val (prevMember, memCount) = + if (rangeValue != null) { + chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) + } else { + null to 1 + } + Text( + memberNames(member, prevMember, memCount), + Modifier + .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) + .weight(1f, false), + fontSize = 13.5.sp, + color = MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - Box { - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null - val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf - val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) - val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } - if (chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - if (showAvatar) { - Column( - Modifier - .padding(top = 8.dp) - .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) - .fillMaxWidth() - .then(swipeableModifier), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.Start - ) { - @Composable - fun MemberNameAndRole(range: State) { - Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { - val member = cItem.chatDir.groupMember - val rangeValue = range.value - val (prevMember, memCount) = - if (rangeValue != null) { - chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) - } else { - null to 1 - } Text( - memberNames(member, prevMember, memCount), - Modifier - .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) - .weight(1f, false), + member.memberRole.text, + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, color = MaterialTheme.colors.secondary, - overflow = TextOverflow.Ellipsis, maxLines = 1 ) - if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - - Text( - member.memberRole.text, - Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), - fontSize = 13.5.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colors.secondary, - maxLines = 1 - ) - } } } - - @Composable - fun Item() { - ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { - androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier, cItem.id, selectedChatItems) - } - Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { - val member = cItem.chatDir.groupMember - Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { - MemberImage(member) - } - Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { - ChatItemViewShortHand(cItem, itemSeparation, range, false) - } - } - } - } - if (cItem.content.showMemberName) { - DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { - MemberNameAndRole(range) - Item() - } - } else { - Item() - } } - } else { - ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + + @Composable + fun Item() { + ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier, cItem.id, selectedChatItems) + } + Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + val member = cItem.chatDir.groupMember + Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + MemberImage(member) + } + Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { + ChatItemViewShortHand(cItem, itemSeparation, range, false) + } + } } - Row( - Modifier - .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) - .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(swipeableOrSelectionModifier) - ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + } + if (cItem.content.showMemberName) { + DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { + MemberNameAndRole(range) + Item() } + } else { + Item() } } } else { ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } - Box( + Row( Modifier - .padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp) + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(if (selectionVisible) Modifier else swipeableModifier) + .then(swipeableOrSelectionModifier) ) { ChatItemViewShortHand(cItem, itemSeparation, range) } } } - } else { // direct message + } else { ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } - Box( - Modifier.padding( - start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp, - end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false), - ) + Modifier + .padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) + .then(if (selectionVisible) Modifier else swipeableModifier) ) { ChatItemViewShortHand(cItem, itemSeparation, range) } } } - if (selectionVisible) { - Box(Modifier.matchParentSize().clickable { - val checked = selectedChatItems.value?.contains(cItem.id) == true - selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) - }) + } else { // direct message + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + + Box( + Modifier.padding( + start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false), + ) + .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) + .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range) + } } } + if (selectionVisible) { + Box(Modifier.matchParentSize().clickable { + val checked = selectedChatItems.value?.contains(cItem.id) == true + selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) + }) + } } - if (itemSeparation.date != null) { - DateSeparator(itemSeparation.date) - } - ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) } + if (itemSeparation.date != null) { + DateSeparator(itemSeparation.date) + } + ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) } + } LazyColumnWithScrollBar( Modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( - top = topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar), + top = topPaddingToContent, bottom = composeViewHeight.value ), reverseLayout = true, additionalBarOffset = composeViewHeight, - additionalTopBar = remember { derivedStateOf { groupReports.value.showBar } }, + additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0)), chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -1522,8 +1734,26 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, groupReports, markChatRead, listState) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopCenter), mergedItems, listState, groupReports) + 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 } @@ -1542,22 +1772,24 @@ fun BoxScope.ChatItemsList( } } -@Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo, groupReports: State) { - LaunchedEffect(remoteHostId, chatInfo.id) { - try { - loadingMoreItems.value = true - if (chatModel.chatStateForContent(groupReports.value.contentTag).totalAfter.value <= 0) return@LaunchedEffect - delay(500) - withContext(Dispatchers.Default) { - apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, groupReports.value.toContentTag(), ChatPagination.Last(ChatPagination.INITIAL_COUNT)) - } - } finally { - loadingMoreItems.value = false - } - } +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(chatsCtx)) return + + delay(500) + loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } +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 +// can be reproduced by forwarding a message to notes that is (ChatPagination.INITIAL_COUNT - 1) away from bottom and going to that message @Composable private fun SmallScrollOnNewMessage(listState: State, reversedChatItems: State>) { val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() } @@ -1616,19 +1848,59 @@ private fun NotifyChatListOnFinishingComposition( @Composable fun BoxScope.FloatingButtons( + chatsCtx: ChatModel.ChatsContext, + reversedChatItems: State>, + chatInfo: State, + topPaddingToContent: Dp, + topPaddingToContentPx: State, loadingMoreItems: MutableState, + loadingTopItems: MutableState, + loadingBottomItems: MutableState, animatedScrollingInProgress: MutableState, mergedItems: State, unreadCount: State, maxHeight: State, composeViewHeight: State, searchValue: State, - groupReports: State, markChatRead: () -> Unit, - listState: State + listState: State, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit ) { val scope = rememberCoroutineScope() - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) + fun scrollToBottom() { + scope.launch { + animatedScrollingInProgress.value = true + tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } + } + } + fun scrollToTopUnread() { + scope.launch { + tryBlockAndSetLoadingMore(loadingMoreItems) { + if (chatsCtx.chatState.splits.value.isNotEmpty()) { + val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + val oldSize = reversedChatItems.value.size + loadMessages(chatInfo.value.id, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) + } + var repeatsLeft = 100 + while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) { + delay(10) + repeatsLeft-- + } + if (oldSize == reversedChatItems.value.size) { + return@tryBlockAndSetLoadingMore + } + } + val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + if (index != -1) { + // scroll to the top unread item + animatedScrollingInProgress.value = true + listState.value.animateScrollToItem(index + 1, -maxHeight.value) + } + } + } + } + val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1654,49 +1926,79 @@ fun BoxScope.FloatingButtons( allowToShowBottomWithArrow.value = shouldShow shouldShow && allow } } + + val requestedTopScroll = remember { mutableStateOf(false) } + val requestedBottomScroll = remember { mutableStateOf(false) } + BottomEndFloatingButton( bottomUnreadCount, showBottomButtonWithCounter, showBottomButtonWithArrow, + requestedBottomScroll, animatedScrollingInProgress, composeViewHeight, onClick = { - scope.launch { - animatedScrollingInProgress.value = true - tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } + if (loadingBottomItems.value || !lastItemsLoaded(chatsCtx)) { + requestedTopScroll.value = false + requestedBottomScroll.value = true + } else { + scrollToBottom() } } ) + LaunchedEffect(Unit) { + launch { + snapshotFlow { loadingTopItems.value } + .drop(1) + .collect { top -> + if (!top && requestedTopScroll.value) { + requestedTopScroll.value = false + scrollToTopUnread() + } + } + } + launch { + snapshotFlow { loadingBottomItems.value } + .drop(1) + .collect { bottom -> + if (!bottom && requestedBottomScroll.value) { + requestedBottomScroll.value = false + scrollToBottom() + } + } + } + } // Don't show top FAB if is in search if (searchValue.value.isNotEmpty()) return val fabSize = 56.dp - val topUnreadCount = remember { derivedStateOf { if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 } } + val topUnreadCount = remember { derivedStateOf { + if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 } + } val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( - Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopEnd), + Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd), topUnreadCount, + requestedTopScroll, animatedScrollingInProgress, onClick = { - val index = mergedItems.value.items.indexOfLast { it.hasUnread() } - if (index != -1) { - // scroll to the top unread item - scope.launch { - animatedScrollingInProgress.value = true - tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) } - } + if (loadingTopItems.value) { + requestedBottomScroll.value = false + requestedTopScroll.value = true + } else { + scrollToTopUnread() } }, onLongClick = { showDropDown.value = true } ) - Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) { + Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).align(Alignment.TopEnd)) { val density = LocalDensity.current val width = remember { mutableStateOf(250.dp) } DefaultDropdownMenu( showDropDown, modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } }, - offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)) + offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent) ) { ItemAction( generalGetString(MR.strings.mark_read), @@ -1711,9 +2013,11 @@ fun BoxScope.FloatingButtons( @Composable fun PreloadItems( + chatsCtx: ChatModel.ChatsContext, chatId: String, ignoreLoadingRequests: MutableSet, - reversedChatItems: State>, + loadingMoreItems: State, + resetListState: State, mergedItems: State, listState: State, remaining: Int, @@ -1724,98 +2028,83 @@ fun PreloadItems( val chatId = rememberUpdatedState(chatId) val loadItems = rememberUpdatedState(loadItems) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) - PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, reversedChatItems, mergedItems, listState, remaining, loadItems) - PreloadItemsAfter(allowLoad, chatId, reversedChatItems, mergedItems, listState, remaining, loadItems) -} - -@Composable -private fun PreloadItemsBefore( - allowLoad: State, - chatId: State, - ignoreLoadingRequests: State>, - reversedChatItems: State>, - mergedItems: State, - listState: State, - remaining: Int, - loadItems: State Boolean>, -) { - KeyChangeEffect(allowLoad.value, chatId.value) { - snapshotFlow { listState.value.firstVisibleItemIndex } - .distinctUntilChanged() - .map { firstVisibleIndex -> - val splits = mergedItems.value.splits - val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) - var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = reversedChatItems.value - if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { - lastIndexToLoadFrom = 0 - } - if (allowLoad.value && lastIndexToLoadFrom != null) { - items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id - } else { - null - } - } - .filterNotNull() - .filter { !ignoreLoadingRequests.value.contains(it) } - .collect { loadFromItemId -> - withBGApi { - val sizeWas = reversedChatItems.value.size - val oldestItemIdWas = reversedChatItems.value.lastOrNull()?.id - val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - if (triedToLoad && sizeWas == reversedChatItems.value.size && oldestItemIdWas == reversedChatItems.value.lastOrNull()?.id) { - ignoreLoadingRequests.value.add(loadFromItemId) - } - } - } - } -} - -@Composable -private fun PreloadItemsAfter( - allowLoad: MutableState, - chatId: State, - reversedChatItems: State>, - mergedItems: State, - listState: State, - remaining: Int, - loadItems: State Boolean>, -) { LaunchedEffect(Unit) { snapshotFlow { chatId.value } .distinctUntilChanged() .filterNotNull() .collect { - allowLoad.value = listState.value.layoutInfo.totalItemsCount == listState.value.layoutInfo.visibleItemsInfo.size + allowLoad.value = false delay(500) allowLoad.value = true } } - LaunchedEffect(chatId.value) { - launch { + if (allowLoad.value && !loadingMoreItems.value) { + LaunchedEffect(chatId.value, resetListState.value) { snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() - .map { firstVisibleIndex -> - val items = reversedChatItems.value - 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) - if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { - items.getOrNull(split.indexRangeInReversed.first)?.id - } else { - null - } - } - .filterNotNull() - .collect { loadFromItemId -> - withBGApi { - loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT)) + .collect { firstVisibleIndex -> + if (!preloadItemsBefore(chatsCtx, firstVisibleIndex, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems)) { + preloadItemsAfter(chatsCtx, firstVisibleIndex, chatId, mergedItems, remaining, loadItems) } + loadLastItems(chatsCtx, chatId, listState, loadItems) } } } } +private suspend fun preloadItemsBefore( + chatsCtx: ChatModel.ChatsContext, + firstVisibleIndex: Int, + chatId: State, + ignoreLoadingRequests: State>, + mergedItems: State, + listState: State, + remaining: Int, + loadItems: State Boolean>, +): Boolean { + 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(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(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(chatsCtx) + if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { + ignoreLoadingRequests.value.add(loadFromItemId) + return false + } + return triedToLoad + } + } + return false +} + +private suspend fun preloadItemsAfter( + chatsCtx: ChatModel.ChatsContext, + firstVisibleIndex: Int, + chatId: State, + mergedItems: State, + remaining: Int, + loadItems: State Boolean>, +) { + 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) + if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { + val loadFromItemId = items.getOrNull(split.indexRangeInReversed.first)?.id ?: return + loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT)) + } +} + val MEMBER_IMAGE_SIZE: Dp = 37.dp @Composable @@ -1827,6 +2116,7 @@ fun MemberImage(member: GroupMember) { private fun TopEndFloatingButton( modifier: Modifier = Modifier, unreadCount: State, + requestedTopScroll: State, animatedScrollingInProgress: State, onClick: () -> Unit, onLongClick: () -> Unit @@ -1840,11 +2130,15 @@ private fun TopEndFloatingButton( elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp), interactionSource = interactionSource, ) { - Text( - unreadCountStr(unreadCount.value), - color = MaterialTheme.colors.primary, - fontSize = 14.sp, - ) + if (requestedTopScroll.value) { + LoadingProgressIndicator() + } else { + Text( + unreadCountStr(unreadCount.value), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } } } } @@ -1861,18 +2155,28 @@ fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): D } } +@Composable +private fun numberOfBottomAppBars(): Int { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val chatBottomBar = remember { appPrefs.chatBottomBar.state } + return if (oneHandUI.value && chatBottomBar.value) { + 2 + } else { + 1 + } +} + @Composable private fun FloatingDate( modifier: Modifier, + topPaddingToContentPx: State, mergedItems: State, listState: State, - groupReports: State ) { val isNearBottom = remember(chatModel.chatId) { mutableStateOf(listState.value.firstVisibleItemIndex == 0) } val nearBottomIndex = remember(chatModel.chatId) { mutableStateOf(if (isNearBottom.value) -1 else 0) } val showDate = remember(chatModel.chatId) { mutableStateOf(false) } val density = LocalDensity.current.density - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier val lastVisibleItemDate = remember { derivedStateOf { @@ -1957,10 +2261,10 @@ private fun FloatingDate( } @Composable -private fun SaveReportsStateOnDispose(groupReports: State, listState: State) { +private fun SaveReportsStateOnDispose(chatsCtx: ChatModel.ChatsContext, listState: State) { DisposableEffect(Unit) { onDispose { - reportsListState = if (groupReports.value.reportsView && 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 } } } @@ -2038,7 +2342,7 @@ private fun DateSeparator(date: Instant) { @Composable private fun MarkItemsReadAfterDelay( - itemKey: String, + itemKey: ChatViewItemKey, itemIds: List, finishedInitialComposition: State, chatId: ChatId, @@ -2062,20 +2366,43 @@ private fun MarkItemsReadAfterDelay( } } +@Composable +fun reportsCount(staleChatId: String?): Int { + return if (staleChatId?.startsWith("#") != true) { + 0 + } else { + remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId }?.chatStats } }.value?.reportsCount ?: 0 + } +} + +@Composable +fun supportUnreadCount(staleChatId: String?): Int { + return if (staleChatId?.startsWith("#") != true) { + 0 + } else { + remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId } } }.value?.supportUnreadCount ?: 0 + } +} + +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 - return mergedItems.value.items.getOrNull((listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> + val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> item.offset <= lastFullyVisibleOffset - }?.index ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest() + }?.key as? ChatViewItemKey + return mergedItems.value.items.getOrNull((mergedItems.value.indexInParentItems[visibleKey?.first] ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest() } private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State, density: Float, fontSizeSqrtMultiplier: Float, mergedItems: State, listState: State): ChatItem? { val lastFullyVisibleOffsetMinusFloatingHeight = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - 50 * density * fontSizeSqrtMultiplier + val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> + item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0 + }?.key as? ChatViewItemKey + return mergedItems.value.items.getOrNull( - (listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> - item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0 - } - ?.index + (mergedItems.value.indexInParentItems[visibleKey?.first] ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.newest()?.item } @@ -2134,23 +2461,28 @@ private fun scrollToItem( } private fun findQuotedItemFromItem( + chatsCtx: ChatModel.ChatsContext, rhId: State, chatInfo: State, scope: CoroutineScope, scrollToItem: (Long) -> Unit, - contentTag: MsgContentTag? + scrollToItemId: MutableState ): (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) + if (item.isReport && chatsCtx.secondaryContextFilter != null) { + scrollToItemId.value = item.quotedItem.itemId + } else { + scrollToItem(item.quotedItem.itemId) + } } else { showQuotedItemDoesNotExistAlert() } @@ -2187,48 +2519,59 @@ private fun BoxScope.BottomEndFloatingButton( unreadCount: State, showButtonWithCounter: State, showButtonWithArrow: State, + requestedBottomScroll: State, animatedScrollingInProgress: State, composeViewHeight: State, onClick: () -> Unit -) = when { - showButtonWithCounter.value && !animatedScrollingInProgress.value -> { - FloatingActionButton( - onClick = onClick, - elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), - backgroundColor = MaterialTheme.colors.secondaryVariant, - ) { - Text( - unreadCountStr(unreadCount.value), - color = MaterialTheme.colors.primary, - fontSize = 14.sp, - ) +) { + when { + showButtonWithCounter.value && !animatedScrollingInProgress.value -> { + FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), + backgroundColor = MaterialTheme.colors.secondaryVariant, + ) { + if (requestedBottomScroll.value) { + LoadingProgressIndicator() + } else { + Text( + unreadCountStr(unreadCount.value), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } + } } - } - showButtonWithArrow.value && !animatedScrollingInProgress.value -> { - FloatingActionButton( - onClick = onClick, - elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), - backgroundColor = MaterialTheme.colors.secondaryVariant, - ) { - Icon( - painter = painterResource(MR.images.ic_keyboard_arrow_down), - contentDescription = null, - tint = MaterialTheme.colors.primary - ) + showButtonWithArrow.value && !animatedScrollingInProgress.value -> { + FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), + backgroundColor = MaterialTheme.colors.secondaryVariant, + ) { + if (requestedBottomScroll.value) { + LoadingProgressIndicator() + } else { + Icon( + painter = painterResource(MR.images.ic_keyboard_arrow_down), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + } } + else -> {} } - else -> {} } @Composable -private fun SelectedChatItem( +fun SelectedListItem( modifier: Modifier, - ciId: Long, - selectedChatItems: State?>, + id: Long, + selectedItems: State?>, ) { - val checked = remember { derivedStateOf { selectedChatItems.value?.contains(ciId) == true } } + val checked = remember { derivedStateOf { selectedItems.value?.contains(id) == true } } Icon( painterResource(if (checked.value) MR.images.ic_check_circle_filled else MR.images.ic_radio_button_unchecked), null, @@ -2245,6 +2588,20 @@ private fun SelectedChatItem( ) } +@Composable +private fun LoadingProgressIndicator() { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.dp + ) + } +} + private fun selectUnselectChatItem( select: Boolean, ci: ChatItem, @@ -2297,34 +2654,36 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List + chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { for (di in deleted) { - if (di.deletedChatItem.chatItem.isReport) { - val toChatItem = di.toChatItem?.chatItem - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) - } + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) } } } @@ -2334,6 +2693,60 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List, forAll: Boolean, onSuccess: () -> Unit = {}) { + if (itemIds.isNotEmpty()) { + withBGApi { + val deleted = chatModel.controller.apiDeleteReceivedReports( + chatRh, + groupId = chatInfo.apiId, + itemIds = itemIds, + mode = if (forAll) CIDeleteMode.cidmBroadcast else CIDeleteMode.cidmInternalMark + ) + if (deleted != null) { + withContext(Dispatchers.Main) { + for (di in deleted) { + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + chatModel.chatsContext.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.chatsContext.removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } + val deletedItem = di.deletedChatItem.chatItem + if (deletedItem.isActiveReport) { + chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id) + } + } + deleted.lastOrNull()?.deletedChatItem?.chatInfo?.let { updatedChatInfo -> + chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + } + } + withContext(Dispatchers.Main) { + for (di in deleted) { + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } + } + } + onSuccess() + } + } + } +} + +private fun archiveItems(rhId: Long?, chatInfo: ChatInfo, selectedChatItems: MutableState?>) { + val itemIds = selectedChatItems.value + if (itemIds != null) { + showArchiveReportsAlert(itemIds.sorted(), chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberActive, archiveReports = { ids, forAll -> + archiveReports(rhId, chatInfo, ids, forAll) { + selectedChatItems.value = null + } + }) + } +} + private fun markUnreadChatAsRead(chatId: String) { val chat = chatModel.chats.value.firstOrNull { it.id == chatId } if (chat?.chatStats?.unreadChat != true) return @@ -2346,9 +2759,9 @@ private fun markUnreadChatAsRead(chatId: String) { false ) if (success) { - withChats { - replaceChat(chatRh, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) - markChatTagRead(chat) + withContext(Dispatchers.Main) { + chatModel.chatsContext.replaceChat(chatRh, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + chatModel.chatsContext.markChatTagRead(chat) } } } @@ -2510,7 +2923,9 @@ fun providerForGallery( } } -private fun keyForItem(item: ChatItem): String = (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() +typealias ChatViewItemKey = Pair + +private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds()) private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { override val longPressTimeoutMillis @@ -2528,7 +2943,7 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf private fun forwardContent(chatItemsIds: 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 ) } @@ -2663,13 +3078,12 @@ fun PreviewChatLayout() { val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, - reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, - groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -2679,11 +3093,13 @@ fun PreviewChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, - showGroupReports = {}, + showReports = {}, + showSupportChats = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -2707,6 +3123,7 @@ fun PreviewChatLayout() { markChatRead = {}, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + closeSearch = {}, onComposed = {}, developerTools = false, showViaProxy = false, @@ -2740,13 +3157,12 @@ fun PreviewGroupChatLayout() { val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, - reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, - groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -2756,11 +3172,13 @@ fun PreviewGroupChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, - showGroupReports = {}, + showReports = {}, + showSupportChats = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = {}, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -2784,6 +3202,7 @@ fun PreviewGroupChatLayout() { markChatRead = {}, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + closeSearch = {}, onComposed = {}, developerTools = false, showViaProxy = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt new file mode 100644 index 0000000000..3c3f99ad94 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -0,0 +1,126 @@ +package chat.simplex.common.views.chat + +import SectionItemView +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.group.removeMember +import chat.simplex.common.views.chat.group.removeMemberDialog +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun ComposeContextPendingMemberActionsView( + rhId: Long?, + groupInfo: GroupInfo, + member: GroupMember +) { + Column( + Modifier + .height(60.dp) + .background(MaterialTheme.colors.surface) + ) { + Divider() + + Row( + Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight() + .weight(1F) + .clickable { + rejectMemberDialog(rhId, member, chatModel, close = { ModalManager.end.closeModal() }) + }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(MR.strings.reject_pending_member_button), color = Color.Red) + } + + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight() + .weight(1F) + .clickable { + acceptMemberDialog(rhId, groupInfo, member, close = { ModalManager.end.closeModal() }) + }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(MR.strings.accept_pending_member_button), color = MaterialTheme.colors.primary) + } + } + } +} + +fun rejectMemberDialog(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.reject_pending_member_alert_title), + confirmText = generalGetString(MR.strings.reject_pending_member_button), + onConfirm = { + removeMember(rhId, member, chatModel, close) + }, + destructive = true, + ) +} + +fun acceptMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.accept_pending_member_alert_title), + text = generalGetString(MR.strings.accept_pending_member_alert_question), + buttons = { + Column { + // Accept as member + SectionItemView({ + AlertManager.shared.hideAlert() + acceptMember(rhId, groupInfo, member, GroupMemberRole.Member, close) + }) { + Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_member), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Accept as observer + SectionItemView({ + AlertManager.shared.hideAlert() + acceptMember(rhId, groupInfo, member, GroupMemberRole.Observer, close) + }) { + Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_observer), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, role: GroupMemberRole, close: (() -> Unit)?) { + withBGApi { + val r = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) + if (r != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) + } + } + close?.invoke() + } +} 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 c413e06599..ca6279fd88 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 @@ -1,4 +1,4 @@ -@file:UseSerializers(UriSerializer::class) +@file:UseSerializers(UriSerializer::class, ComposeMessageSerializer::class) package chat.simplex.common.views.chat import androidx.compose.foundation.background @@ -11,20 +11,20 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.* 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.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.* @@ -33,10 +33,15 @@ import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import java.io.File import java.net.URI import java.nio.file.Files +const val MAX_NUMBER_OF_MENTIONS = 3 + @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @@ -63,23 +68,57 @@ data class LiveMessage( val sent: Boolean ) +typealias MentionedMembers = Map + +@Serializable +data class ComposeMessage( + val text: String = "", + val selection: TextRange = TextRange.Zero +) { + constructor(text: String): this(text, TextRange(text.length)) +} + +@Serializer(forClass = TextRange::class) +object ComposeMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextRange", PrimitiveKind.LONG) + override fun serialize(encoder: Encoder, value: TextRange) = + encoder.encodeLong(packInts(value.start, value.end)) + override fun deserialize(decoder: Decoder): TextRange = + decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) } +} + @Serializable data class ComposeState( - val message: String = "", + val message: ComposeMessage = ComposeMessage(), + val parsedMessage: List = emptyList(), val liveMessage: LiveMessage? = null, val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, val inProgress: Boolean = false, - val useLinkPreviews: Boolean + val useLinkPreviews: Boolean, + val mentions: MentionedMembers = emptyMap() ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( - editingItem.content.text, + ComposeMessage(editingItem.content.text), + editingItem.formattedText ?: FormattedText.plain(editingItem.content.text), liveMessage, chatItemPreview(editingItem), ComposeContextItem.EditingItem(editingItem), - useLinkPreviews = useLinkPreviews + useLinkPreviews = useLinkPreviews, + mentions = editingItem.mentions ?: emptyMap() ) + val memberMentions: Map + get() = this.mentions.mapNotNull { + val memberRef = it.value.memberRef + + if (memberRef != null) { + it.key to memberRef.groupMemberId + } else { + null + } + }.toMap() + val editing: Boolean get() = when (contextItem) { @@ -100,7 +139,7 @@ data class ComposeState( get() = when (contextItem) { is ComposeContextItem.ReportedItem -> { when (contextItem.reason) { - is ReportReason.Other -> message.isNotEmpty() + is ReportReason.Other -> message.text.isNotEmpty() else -> true } } @@ -112,12 +151,12 @@ data class ComposeState( is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport + else -> message.text.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } val endLiveDisabled: Boolean - get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = liveMessage != null && message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem val linkPreviewAllowed: Boolean get() = @@ -160,7 +199,7 @@ data class ComposeState( } val empty: Boolean - get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem companion object { fun saver(): Saver, *> = Saver( @@ -170,6 +209,18 @@ data class ComposeState( } ) } + + fun mentionMemberName(name: String): String { + var n = 0 + var tryName = name + + while (mentions.containsKey(tryName)) { + n++ + tryName = "${name}_$n" + } + + return tryName + } } private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) @@ -223,7 +274,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) if (fileName != null) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.FilePreview(fileName, uri)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.FilePreview(fileName, uri)) } } else if (fileSize != null) { AlertManager.shared.showAlertMsg( @@ -276,25 +327,27 @@ suspend fun MutableState.processPickedMedia(uris: List, text: } } if (imagesPreview.isNotEmpty()) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) } } @Composable fun ComposeView( + rhId: Long?, chatModel: ChatModel, + chatsCtx: ChatModel.ChatsContext, chat: Chat, composeState: MutableState, attachmentOption: MutableState, - showChooseAttachment: () -> Unit + showChooseAttachment: () -> Unit, + focusRequester: FocusRequester?, ) { val cancelledLinks = rememberSaveable { mutableSetOf() } fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) - fun parseMessage(msg: String): Pair { - if (msg.isBlank()) return null to false - val parsedMsg = parseToMarkdown(msg) ?: return null to false + fun getSimplexLink(parsedMsg: List?): Pair { + if (parsedMsg == null) return null to false val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink } return link?.text to simplexLink @@ -302,7 +355,7 @@ fun ComposeView( val linkUrl = rememberSaveable { mutableStateOf(null) } // default value parsed because of draft - val hasSimplexLink = rememberSaveable { mutableStateOf(parseMessage(composeState.value.message).second) } + val hasSimplexLink = rememberSaveable { mutableStateOf(getSimplexLink(parseToMarkdown(composeState.value.message.text)).second) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() @@ -310,7 +363,6 @@ fun ComposeView( val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } - AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } fun loadLinkPreview(url: String, wait: Long? = null) { @@ -330,11 +382,11 @@ fun ComposeView( } } - fun showLinkPreview(s: String) { + fun showLinkPreview(parsedMessage: List?) { prevLinkUrl.value = linkUrl.value - val parsed = parseMessage(s) - linkUrl.value = parsed.first - hasSimplexLink.value = parsed.second + val linkParsed = getSimplexLink(parsedMessage) + linkUrl.value = linkParsed.first + hasSimplexLink.value = linkParsed.second val url = linkUrl.value if (url != null) { if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { @@ -403,27 +455,28 @@ fun ComposeView( } } - suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map): ChatItem? { val cInfo = chat.chatInfo val chatItems = if (chat.chatInfo.chatType == ChatType.Local) chatModel.controller.apiCreateChatItems( rh = chat.remoteHostId, noteFolderId = chat.chatInfo.apiId, - composedMessages = listOf(ComposedMessage(file, null, mc)) + composedMessages = listOf(ComposedMessage(file, null, mc, mentions)) ) else chatModel.controller.apiSendMessages( rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), live = live, ttl = ttl, - composedMessages = listOf(ComposedMessage(file, quoted, mc)) + composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) ) if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> - withChats { - addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + withContext(Dispatchers.Main) { + chatsCtx.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -437,7 +490,7 @@ fun ComposeView( val cs = composeState.value var sent: List? var lastMessageFailedToSend: ComposeState? = null - val msgText = text ?: cs.message + val msgText = text ?: cs.message.text fun sending() { composeState.value = composeState.value.copy(inProgress = true) @@ -448,15 +501,17 @@ fun ComposeView( rh = rhId, toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, + toScope = chat.chatInfo.groupChatScope(), fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, + fromScope = fromChatInfo.groupChatScope(), itemIds = forwardedItem.map { it.id }, ttl = ttl ) - withChats { + withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - addChatItem(rhId, chat.chatInfo, chatItem) + chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem) } } @@ -473,7 +528,8 @@ fun ComposeView( fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { - val url = parseMessage(msgText).first + val parsedMsg = parseToMarkdown(msgText) + val url = getSimplexLink(parsedMsg).first val lp = composePreview.linkPreview if (lp != null && url == lp.uri) { MsgContent.MCLink(msgText, preview = lp) @@ -512,16 +568,21 @@ fun ComposeView( } } + fun showReportsInSupportChatAlert() { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_sent_alert_title), + text = generalGetString(MR.strings.report_sent_alert_msg_view_in_support_chat), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.dont_show_again), + onDismiss = { + chatModel.controller.appPrefs.showReportsInSupportChatAlert.set(false) + }, + ) + } + 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 { - cItems.forEach { chatItem -> - addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) - } - } - } - + if (chatModel.controller.appPrefs.showReportsInSupportChatAlert.get()) showReportsInSupportChatAlert() return cItems?.map { it.chatItem } } @@ -529,8 +590,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) { + chatsCtx.updateContact(chat.remoteHostId, contact) } } } @@ -543,12 +604,15 @@ fun ComposeView( rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent), + updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) - if (updatedItem != null) withChats { - upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + if (updatedItem != null) { + withContext(Dispatchers.Main) { + chatsCtx.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + } } return updatedItem?.chatItem } @@ -572,10 +636,10 @@ fun ComposeView( if (sent == null) { lastMessageFailedToSend = constructFailedMessage(cs) } - if (cs.message.isNotEmpty()) { + if (cs.message.text.isNotEmpty()) { sent?.mapIndexed { index, message -> if (index == sent!!.lastIndex) { - send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl) + send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions) } else { message } @@ -686,7 +750,8 @@ fun ComposeView( } val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, - ttl = ttl + ttl = ttl, + mentions = cs.memberMentions ) sent = if (sendResult != null) listOf(sendResult) else null if (sent == null && index == msgs.lastIndex && cs.liveMessage == null) { @@ -719,21 +784,22 @@ fun ComposeView( } } - fun onMessageChange(s: String) { - composeState.value = composeState.value.copy(message = s) - if (isShortEmoji(s)) { - textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + fun onMessageChange(s: ComposeMessage) { + val parsedMessage = parseToMarkdown(s.text) + composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text)) + if (isShortEmoji(s.text)) { + textStyle.value = if (s.text.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { textStyle.value = smallFont if (composeState.value.linkPreviewAllowed) { - if (s.isNotEmpty()) { - showLinkPreview(s) + if (s.text.isNotEmpty()) { + showLinkPreview(parsedMessage) } else { resetLinkPreview() hasSimplexLink.value = false } - } else if (s.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { - hasSimplexLink.value = parseMessage(s).second + } else if (s.text.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { + hasSimplexLink.value = getSimplexLink(parsedMessage).second } else { hasSimplexLink.value = false } @@ -801,7 +867,7 @@ fun ComposeView( suspend fun sendLiveMessage() { val cs = composeState.value - val typedMsg = cs.message + val typedMsg = cs.message.text if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { val ci = sendMessageAsync(typedMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { @@ -822,21 +888,21 @@ fun ComposeView( val typedMsg = composeState.value.message val liveMessage = composeState.value.liveMessage if (liveMessage != null) { - val sentMsg = liveMessageToSend(liveMessage, typedMsg) + val sentMsg = liveMessageToSend(liveMessage, typedMsg.text) if (sentMsg != null) { val ci = sendMessageAsync(sentMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg.text, sentMsg = sentMsg, sent = true)) } - } else if (liveMessage.typedMsg != typedMsg) { - composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) + } else if (liveMessage.typedMsg != typedMsg.text) { + composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg.text)) } } } 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 = chatsCtx.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } @@ -904,16 +970,16 @@ fun ComposeView( fun contextItemView() { when (val contextItem = composeState.value.contextItem) { ComposeContextItem.NoContextItem -> {} - is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatInfo = chat.chatInfo) { clearState() } - is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { + is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatInfo = chat.chatInfo, contextIconColor = Color.Red) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } } @@ -932,7 +998,7 @@ fun ComposeView( if (chatModel.chatId.value == null) return@LaunchedEffect when (val shared = chatModel.sharedContent.value) { - is SharedContent.Text -> onMessageChange(shared.text) + is SharedContent.Text -> onMessageChange(ComposeMessage(shared.text)) is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) is SharedContent.Forward -> composeState.value = composeState.value.copy( @@ -944,12 +1010,26 @@ fun ComposeView( chatModel.sharedContent.value = null } - val userCanSend = rememberUpdatedState(chat.chatInfo.userCanSend) val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled) - val userIsObserver = rememberUpdatedState(chat.userIsObserver) + val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) Column { + if ( + chat.chatInfo is ChatInfo.Group + && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + && chatsCtx.secondaryContextFilter.groupScopeInfo is GroupChatScopeInfo.MemberSupport + && chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ != null + && chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_.memberPending + && composeState.value.contextItem == ComposeContextItem.NoContextItem + && composeState.value.preview == ComposePreview.NoPreview + ) { + ComposeContextPendingMemberActionsView( + rhId = rhId, + groupInfo = chat.chatInfo.groupInfo, + member = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ + ) + } if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } @@ -957,8 +1037,8 @@ fun ComposeView( if (ctx is ComposeContextItem.ReportedItem) { ReportReasonView(ctx.reason) } - val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) - val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) + val simplexLinkProhibited = chatsCtx.secondaryContextFilter == null && hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) + val fileProhibited = chatsCtx.secondaryContextFilter == null && composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { if (simplexLinkProhibited) { @@ -987,7 +1067,10 @@ fun ComposeView( Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { Divider() Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) + val isGroupAndProhibitedFiles = + chatsCtx.secondaryContextFilter == null + && chat.chatInfo is ChatInfo.Group + && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) val attachmentClicked = if (isGroupAndProhibitedFiles) { { AlertManager.shared.showAlertMsg( @@ -1001,7 +1084,6 @@ fun ComposeView( val attachmentEnabled = !composeState.value.attachmentDisabled && sendMsgEnabled.value - && userCanSend.value && !isGroupAndProhibitedFiles && !nextSendGrpInv.value IconButton( @@ -1047,8 +1129,8 @@ fun ComposeView( } } - LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { - if (!chat.chatInfo.userCanSend) { + LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { + if (!chat.chatInfo.sendMsgEnabled) { clearCurrentDraft() clearState() } @@ -1056,7 +1138,7 @@ fun ComposeView( KeyChangeEffect(chatModel.chatId.value) { prevChatId -> val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { sendMessage(null) resetLinkPreview() clearPrevDraft(prevChatId) @@ -1104,13 +1186,12 @@ fun ComposeView( chat.chatInfo is ChatInfo.Direct, liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, sendMsgEnabled = sendMsgEnabled.value, + userCantSendReason = userCantSendReason.value, sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), nextSendGrpInv = nextSendGrpInv.value, needToAllowVoiceToContact, allowedVoiceByPrefs, allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, - userCanSend = userCanSend.value, sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, @@ -1128,7 +1209,8 @@ fun ComposeView( editPrevMessage = ::editPrevMessage, onFilesPasted = { composeState.onFilesAttached(it) }, onMessageChange = ::onMessageChange, - textStyle = textStyle + textStyle = textStyle, + focusRequester = focusRequester, ) } } 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/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 1657a1f0b7..1501fb7938 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -31,7 +31,7 @@ fun ContextItemView( contextItems: List, contextIcon: Painter, showSender: Boolean = true, - chatType: ChatType, + chatInfo: ChatInfo, contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { @@ -64,6 +64,11 @@ fun ContextItemView( inlineContent = inlineContent, linkMode = SimplexLinkMode.DESCRIPTION, modifier = Modifier.fillMaxWidth(), + mentions = contextItem.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } ) } @@ -126,7 +131,7 @@ fun ContextItemView( ContextMsgPreview(contextItem, lines = 3) } } else if (contextItems.isNotEmpty()) { - Text(String.format(generalGetString(if (chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) + Text(String.format(generalGetString(if (chatInfo.chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) } } IconButton(onClick = cancelContextItem) { @@ -147,7 +152,7 @@ fun PreviewContextItemView() { ContextItemView( contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello")), contextIcon = painterResource(MR.images.ic_edit_filled), - chatType = ChatType.Direct + chatInfo = Chat.sampleData.chatInfo ) {} } } 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 e449831ee0..ac722783a3 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 @@ -7,12 +7,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.* @@ -21,41 +21,55 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @Composable -fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>, onTop: Boolean) { - val onBackClicked = { selectedChatItems.value = null } +fun BoxScope.SelectedItemsCounterToolbar(selectedItems: MutableState?>, onTop: Boolean, selectAll: (() -> Unit)? = null) { + val onBackClicked = { selectedItems.value = null } BackHandler(onBack = onBackClicked) - val count = selectedChatItems.value?.size ?: 0 - DefaultAppBar( - navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, - title = { - Text( - if (count == 0) { - stringResource(MR.strings.selected_chat_items_nothing_selected) - } else { - stringResource(MR.strings.selected_chat_items_selected_n).format(count) - }, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - onTitleClick = null, - onTop = onTop, - onSearchValueChanged = {}, - ) + val count = selectedItems.value?.size ?: 0 + Box(if (onTop) Modifier else Modifier.imePadding()) { + DefaultAppBar( + navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, + title = { + Text( + if (count == 0) { + stringResource(MR.strings.selected_chat_items_nothing_selected) + } else { + stringResource(MR.strings.selected_chat_items_selected_n).format(count) + }, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onTitleClick = null, + onTop = onTop, + onSearchValueChanged = {}, + buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}} + ) + } } @Composable -fun SelectedItemsBottomToolbar( +private fun SelectAllButton(onClick: () -> Unit) { + IconButton(onClick) { + Icon( + painterResource(MR.images.ic_checklist), stringResource(MR.strings.back), Modifier.height(24.dp), tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +fun SelectedItemsButtonsToolbar( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, - reversedChatItems: State>, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible + archiveItems: () -> Unit, moderateItems: () -> Unit, forwardItems: () -> Unit, ) { val deleteEnabled = remember { mutableStateOf(false) } val deleteForEveryoneEnabled = remember { mutableStateOf(false) } + val canArchiveReports = remember { mutableStateOf(false) } val canModerate = remember { mutableStateOf(false) } val moderateEnabled = remember { mutableStateOf(false) } val forwardEnabled = remember { mutableStateOf(false) } @@ -63,7 +77,7 @@ fun SelectedItemsBottomToolbar( val forwardCountProhibited = remember { mutableStateOf(false) } Box { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty - ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}) + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) Row( Modifier .matchParentSize() @@ -78,7 +92,7 @@ fun SelectedItemsBottomToolbar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - IconButton({ deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !deleteCountProhibited.value) { + IconButton({ if (canArchiveReports.value) archiveItems() else deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !deleteCountProhibited.value) { Icon( painterResource(MR.images.ic_delete), null, @@ -87,28 +101,29 @@ fun SelectedItemsBottomToolbar( ) } - IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value) { + IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value && chatsCtx.secondaryContextFilter == null) { Icon( painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), - tint = if (!moderateEnabled.value || deleteCountProhibited.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error + tint = if (!moderateEnabled.value || deleteCountProhibited.value || chatsCtx.secondaryContextFilter != null) MaterialTheme.colors.secondary else MaterialTheme.colors.error ) } - IconButton({ forwardItems() }, enabled = forwardEnabled.value && !forwardCountProhibited.value) { + IconButton({ forwardItems() }, enabled = forwardEnabled.value && !forwardCountProhibited.value && chatsCtx.secondaryContextFilter == null) { Icon( painterResource(MR.images.ic_forward), null, Modifier.size(22.dp), - tint = if (!forwardEnabled.value || forwardCountProhibited.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + tint = if (!forwardEnabled.value || forwardCountProhibited.value || chatsCtx.secondaryContextFilter != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } } Divider(Modifier.align(Alignment.TopStart)) } - LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) { - recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + 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) } } @@ -117,6 +132,7 @@ private fun recheckItems(chatInfo: ChatInfo, selectedChatItems: MutableState?>, deleteEnabled: MutableState, deleteForEveryoneEnabled: MutableState, + canArchiveReports: MutableState, canModerate: MutableState, moderateEnabled: MutableState, forwardEnabled: MutableState, @@ -130,6 +146,7 @@ private fun recheckItems(chatInfo: ChatInfo, val selected = selectedChatItems.value ?: return var rDeleteEnabled = true var rDeleteForEveryoneEnabled = true + var rCanArchiveReports = true var rModerateEnabled = true var rOnlyOwnGroupItems = true var rForwardEnabled = true @@ -138,6 +155,7 @@ private fun recheckItems(chatInfo: ChatInfo, if (selected.contains(ci.id)) { rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport + rCanArchiveReports = rCanArchiveReports && ci.isActiveReport && ci.chatDir !is CIDirection.GroupSnd && chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport @@ -147,10 +165,11 @@ private fun recheckItems(chatInfo: ChatInfo, rModerateEnabled = rModerateEnabled && !rOnlyOwnGroupItems deleteEnabled.value = rDeleteEnabled deleteForEveryoneEnabled.value = rDeleteForEveryoneEnabled + canArchiveReports.value = rCanArchiveReports moderateEnabled.value = rModerateEnabled forwardEnabled.value = rForwardEnabled selectedChatItems.value = rSelectedChatItems } private fun possibleToModerate(chatInfo: ChatInfo): Boolean = - chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Admin + chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index e2b44478af..5710f09ed5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -12,9 +12,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.* @@ -39,12 +39,11 @@ fun SendMsgView( isDirectChat: Boolean, liveMessageAlertShown: SharedPreference, sendMsgEnabled: Boolean, + userCantSendReason: Pair?, sendButtonEnabled: Boolean, nextSendGrpInv: Boolean, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, - userIsObserver: Boolean, - userCanSend: Boolean, sendButtonColor: Color = MaterialTheme.colors.primary, allowVoiceToContact: () -> Unit, timedMessageAllowed: Boolean = false, @@ -56,11 +55,11 @@ fun SendMsgView( cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, onFilesPasted: (List) -> Unit, - onMessageChange: (String) -> Unit, - textStyle: MutableState -) { + onMessageChange: (ComposeMessage) -> Unit, + textStyle: MutableState, + focusRequester: FocusRequester? = null, + ) { val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } - val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp) Box(Modifier.padding(padding)) { val cs = composeState.value @@ -73,42 +72,50 @@ fun SendMsgView( false } } - val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && + val showVoiceButton = !nextSendGrpInv && cs.message.text.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || cs.endLiveDisabled || !sendButtonEnabled - val clicksOnTextFieldDisabled = !sendMsgEnabled || cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress + val clicksOnTextFieldDisabled = !sendMsgEnabled || cs.preview is ComposePreview.VoicePreview || cs.inProgress PlatformTextField( composeState, sendMsgEnabled, + disabledText = userCantSendReason?.first, sendMsgButtonDisabled, textStyle, showDeleteTextButton, - userIsObserver, if (clicksOnTextFieldDisabled) "" else placeholder, showVoiceButton, onMessageChange, editPrevMessage, - onFilesPasted + onFilesPasted, + focusRequester ) { if (!cs.inProgress) { sendMessage(null) } } if (clicksOnTextFieldDisabled) { - Box( - Modifier - .matchParentSize() - .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.observer_cant_send_message_title), - text = generalGetString(MR.strings.observer_cant_send_message_desc) - ) - }) - ) + if (userCantSendReason != null) { + Box( + Modifier + .matchParentSize() + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.cant_send_message_alert_title), + text = userCantSendReason.second + ) + }) + ) + } else { + Box( + Modifier + .matchParentSize() + ) + } } if (showDeleteTextButton.value) { DeleteTextButton(composeState) @@ -132,11 +139,11 @@ fun SendMsgView( Row(verticalAlignment = Alignment.CenterVertically) { val stopRecOnNextClick = remember { mutableStateOf(false) } when { - needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> { - DisallowedVoiceButton(userCanSend) { + needToAllowVoiceToContact || !allowedVoiceByPrefs -> { + DisallowedVoiceButton { if (needToAllowVoiceToContact) { showNeedToAllowVoiceAlert(allowVoiceToContact) - } else if (!allowedVoiceByPrefs) { + } else { showDisabledVoiceAlert(isDirectChat) } } @@ -152,7 +159,7 @@ fun SendMsgView( && cs.contextItem is ComposeContextItem.NoContextItem ) { Spacer(Modifier.width(12.dp)) - StartLiveMessageButton(userCanSend) { + StartLiveMessageButton { if (composeState.value.preview is ComposePreview.NoPreview) { startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) } @@ -160,7 +167,7 @@ fun SendMsgView( } } } - cs.liveMessage?.sent == false && cs.message.isEmpty() -> { + cs.liveMessage?.sent == false && cs.message.text.isEmpty() -> { CancelLiveMessageButton { cancelLiveMessage?.invoke() } @@ -280,7 +287,7 @@ private fun CustomDisappearingMessageDialog( @Composable private fun BoxScope.DeleteTextButton(composeState: MutableState) { IconButton( - { composeState.value = composeState.value.copy(message = "") }, + { composeState.value = composeState.value.copy(message = ComposeMessage()) }, Modifier.align(Alignment.TopEnd).size(36.dp) ) { Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary) @@ -340,8 +347,8 @@ private fun RecordVoiceView(recState: MutableState, stopRecOnNex } @Composable -private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) { - IconButton(onClick, Modifier.size(36.dp), enabled = enabled) { +private fun DisallowedVoiceButton(onClick: () -> Unit) { + IconButton(onClick, Modifier.size(36.dp)) { Icon( painterResource(MR.images.ic_keyboard_voice), stringResource(MR.strings.icon_descr_record_voice_message), @@ -457,14 +464,13 @@ private fun SendMsgButton( } @Composable -private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) { +private fun StartLiveMessageButton(onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } val ripple = remember { ripple(bounded = false, radius = 24.dp) } Box( modifier = Modifier.requiredSize(36.dp) .clickable( onClick = onClick, - enabled = enabled, role = Role.Button, interactionSource = interactionSource, indication = ripple @@ -474,7 +480,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) { Icon( BoltFilled, stringResource(MR.strings.icon_descr_send_message), - tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + tint = MaterialTheme.colors.primary, modifier = Modifier .size(36.dp) .padding(4.dp) @@ -573,12 +579,11 @@ fun PreviewSendMsgView() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + userCantSendReason = null, sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, placeholder = "", @@ -586,7 +591,7 @@ fun PreviewSendMsgView() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -609,12 +614,11 @@ fun PreviewSendMsgViewEditing() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + userCantSendReason = null, sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, placeholder = "", @@ -622,7 +626,7 @@ fun PreviewSendMsgViewEditing() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -645,12 +649,11 @@ fun PreviewSendMsgViewInProgress() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + userCantSendReason = null, sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, placeholder = "", @@ -658,7 +661,7 @@ fun PreviewSendMsgViewInProgress() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index e670fae5ef..91f7af2b95 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -68,7 +68,7 @@ private fun VerifyCodeLayout( } } - QRCode(connectionCode, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF)) + QRCode(connectionCode, small = true, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF)) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.weight(2f)) 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..827af085ea 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) { @@ -56,17 +55,27 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea GroupPreferencesView(chatModel, rhId, groupInfo.id, close) } }, + openMemberAdmission = { + ModalManager.end.showCustomModal { close -> + MemberAdmissionView( + chat.simplex.common.platform.chatModel, + rhId, + groupInfo.id, + close + ) + } + }, inviteMembers = { allowModifyMembers = false withLongRunningApi(slow = 120_000) { 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 @@ -94,8 +103,9 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List { .asSequence() .map { it.chatInfo } .filterIsInstance() + .filter { it.sendMsgEnabled } .map { it.contact } - .filter { c -> c.sendMsgEnabled && !c.nextSendGrpInv && c.contactId !in memberContactIds && c.anyNameContains(s) + .filter { c -> !c.nextSendGrpInv && c.contactId !in memberContactIds && c.anyNameContains(s) } .sortedBy { it.displayName.lowercase() } .toList() @@ -111,6 +121,7 @@ fun AddGroupMembersLayout( allowModifyMembers: Boolean, searchText: MutableState, openPreferences: () -> Unit, + openMemberAdmission: () -> Unit, inviteMembers: () -> Unit, clearSelection: () -> Unit, addContact: (Long) -> Unit, @@ -145,7 +156,7 @@ fun AddGroupMembersLayout( horizontalArrangement = Arrangement.Center ) { ChatInfoToolbarTitle( - ChatInfo.Group(groupInfo), + ChatInfo.Group(groupInfo, groupChatScope = null), imageSize = 60.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight ) @@ -166,6 +177,9 @@ fun AddGroupMembersLayout( } else { SectionView { if (creatingGroup) { + SectionItemView(openMemberAdmission) { + Text(stringResource(MR.strings.set_member_admission)) + } SectionItemView(openPreferences) { Text(stringResource(MR.strings.set_group_preferences)) } @@ -377,6 +391,7 @@ fun PreviewAddGroupMembersLayout() { allowModifyMembers = true, searchText = remember { mutableStateOf(TextFieldValue("")) }, openPreferences = {}, + openMemberAdmission = {}, inviteMembers = {}, clearSelection = {}, addContact = {}, 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 9b2986ef83..db6eff562e 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 @@ -8,6 +8,8 @@ import SectionItemViewLongClickable import SectionSpacer import SectionTextFooter import SectionView +import androidx.compose.animation.* +import androidx.compose.animation.core.animateDpAsState import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* @@ -17,6 +19,7 @@ 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.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString @@ -29,24 +32,37 @@ 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.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.database.TtlOptions import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +val MEMBER_ROW_AVATAR_SIZE = 42.dp +val MEMBER_ROW_VERTICAL_PADDING = 8.dp @Composable -fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { +fun ModalData.GroupChatInfoView( + chatsCtx: ChatModel.ChatsContext, + rhId: Long?, + chatId: String, + groupLink: CreatedConnLink?, + groupLinkMemberRole: GroupMemberRole?, + selectedItems: MutableState?>, + appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, + scrollToItemId: MutableState, + onGroupLinkUpdated: (Pair?) -> Unit, + close: () -> Unit, + onSearchClicked: () -> Unit +) { BackHandler(onBack = close) // TODO derivedStateOf? val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } @@ -55,7 +71,13 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { val groupInfo = chat.chatInfo.groupInfo val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } + val chatItemTTL = remember(groupInfo.id) { mutableStateOf(if (groupInfo.chatItemTTL != null) ChatItemTTL.fromSeconds(groupInfo.chatItemTTL) else null) } + val deletingItems = rememberSaveable(groupInfo.id) { mutableStateOf(false) } val scope = rememberCoroutineScope() + val activeSortedMembers = remember { chatModel.groupMembers }.value + .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } + .sortedByDescending { it.memberRole } + GroupChatInfoLayout( chat, groupInfo, @@ -66,12 +88,22 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, - members = remember { chatModel.groupMembers }.value - .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } - .sortedByDescending { it.memberRole }, + chatItemTTL = chatItemTTL, + setChatItemTTL = { + if (it == chatItemTTL.value) { + return@GroupChatInfoLayout + } + val previousChatTTL = chatItemTTL.value + chatItemTTL.value = it + + setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + }, + activeSortedMembers = activeSortedMembers, developerTools, onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, groupLink, + selectedItems, + appBar, scrollToItemId, addMembers = { scope.launch(Dispatchers.Default) { @@ -95,7 +127,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, stats, code, chatModel, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, closeCurrent) { closeCurrent() close() } @@ -109,6 +141,17 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin addOrEditWelcomeMessage = { ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, rhId, groupInfo, close) } }, + openMemberSupport = { + ModalManager.end.showCustomModal { close -> + MemberSupportView( + rhId, + chat, + groupInfo, + scrollToItemId, + close + ) + } + }, openPreferences = { ModalManager.end.showCustomModal { close -> GroupPreferencesView( @@ -125,7 +168,8 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin manageGroupLink = { ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } }, - onSearchClicked = onSearchClicked + onSearchClicked = onSearchClicked, + deletingItems = deletingItems ) } } @@ -149,8 +193,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() @@ -195,17 +239,23 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe text = generalGetString(messageId), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { - withBGApi { - val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId) - if (updatedMember != null) { - withChats { - upsertGroupMember(rhId, groupInfo, updatedMember) - } - withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, updatedMember) - } - } - } + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId)) + }, + destructive = true, + ) +} + +private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { + val messageId = if (groupInfo.businessChat == null) + MR.strings.members_will_be_removed_from_group_cannot_be_undone + else + MR.strings.members_will_be_removed_from_chat_cannot_be_undone + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.button_remove_members_question), + text = generalGetString(messageId), + confirmText = generalGetString(MR.strings.remove_member_confirmation), + onConfirm = { + removeMembers(rhId, groupInfo, memberIds, onSuccess) }, destructive = true, ) @@ -242,16 +292,17 @@ fun MuteButton( chat: Chat, groupInfo: GroupInfo ) { - val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + val notificationMode = remember { mutableStateOf(groupInfo.chatSettings.enableNtfs) } + val nextNotificationMode by remember { derivedStateOf { notificationMode.value.nextMode(true) } } InfoViewActionButton( modifier = modifier, - icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), - title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), + icon = painterResource(nextNotificationMode.icon), + title = generalGetString(nextNotificationMode.text(true)), disabled = !groupInfo.ready, disabledLook = !groupInfo.ready, onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled.value, chatModel, ntfsEnabled) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNotificationMode, chatModel, notificationMode) } ) } @@ -278,6 +329,40 @@ fun AddGroupMembersButton( ) } +@Composable +fun UserSupportChatButton( + chat: Chat, + groupInfo: GroupInfo, + scrollToItemId: MutableState +) { + val scope = rememberCoroutineScope() + + SettingsActionItemWithContent( + painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), + stringResource(MR.strings.button_support_chat), + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary), + ) { + if (chat.supportUnreadCount > 0) { + UnreadBadge( + text = unreadCountStr(chat.supportUnreadCount), + backgroundColor = MaterialTheme.colors.primary + ) + } + } +} + @Composable fun ModalData.GroupChatInfoLayout( chat: Chat, @@ -285,22 +370,28 @@ fun ModalData.GroupChatInfoLayout( currentUser: User, sendReceipts: State, setSendReceipts: (SendReceipts) -> Unit, - members: List, + chatItemTTL: MutableState, + setChatItemTTL: (ChatItemTTL?) -> Unit, + activeSortedMembers: List, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, - groupLink: String?, + groupLink: CreatedConnLink?, + selectedItems: MutableState?>, + appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, addMembers: () -> Unit, showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, addOrEditWelcomeMessage: () -> Unit, + openMemberSupport: () -> Unit, openPreferences: () -> Unit, deleteGroup: () -> Unit, clearChat: () -> Unit, leaveGroup: () -> Unit, manageGroupLink: () -> Unit, close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, - onSearchClicked: () -> Unit + onSearchClicked: () -> Unit, + deletingItems: State ) { val listState = remember { appBarHandler.listState } val scope = rememberCoroutineScope() @@ -308,20 +399,38 @@ fun ModalData.GroupChatInfoLayout( scope.launch { listState.scrollToItem(0) } } val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } } - val filteredMembers = remember(members) { + val filteredMembers = remember(activeSortedMembers) { derivedStateOf { val s = searchText.value.text.trim().lowercase() - if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) } + if (s.isEmpty()) activeSortedMembers else activeSortedMembers.filter { m -> m.anyNameContains(s) } } } Box { val oneHandUI = remember { appPrefs.oneHandUI.state } + val selectedItemsBarHeight = if (selectedItems.value != null) AppBarHeight * fontSizeSqrtMultiplier else 0.dp + val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val imePadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() LazyColumnWithScrollBar( state = listState, contentPadding = if (oneHandUI.value) { - PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) + PaddingValues( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, + bottom = navBarPadding + + imePadding + + selectedItemsBarHeight + + // TODO: that's workaround but works. Actually, something in the codebase doesn't consume padding for AppBar and it produce + // different padding when the user has NavigationBar and doesn't have it with ime shown (developer options helps to test it nav bars) + (if (navBarPadding > 0.dp && imePadding > 0.dp) 0.dp else AppBarHeight * fontSizeSqrtMultiplier) + ) } else { - PaddingValues(top = topPaddingToContent(false)) + PaddingValues( + top = topPaddingToContent(false), + bottom = if (imePadding > 0.dp) { + imePadding + selectedItemsBarHeight + } else { + navBarPadding + selectedItemsBarHeight + } + ) } ) { item { @@ -360,6 +469,40 @@ fun ModalData.GroupChatInfoLayout( SectionSpacer() + var anyTopSectionRowShow = false + SectionView { + if (groupInfo.canAddMembers && groupInfo.businessChat == null) { + anyTopSectionRowShow = true + if (groupLink == null) { + CreateGroupLinkButton(manageGroupLink) + } else { + GroupLinkButton(manageGroupLink) + } + } + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton(chat) { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + if ( + groupInfo.membership.memberActive && + (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) + ) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } + } + if (anyTopSectionRowShow) { + SectionDividerSpaced(maxBottomPadding = false) + } + SectionView { if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { EditGroupProfileButton(editGroupProfile) @@ -369,19 +512,17 @@ fun ModalData.GroupChatInfoLayout( } val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences GroupPreferencesButton(prefsTitleId, openPreferences) - if (groupInfo.canModerate) { - GroupReportsButton { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) - } - } - } - if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + } + val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + SectionTextFooter(stringResource(footerId)) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + SectionView { + if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) } else { SendReceiptsOptionDisabled() } - WallpaperButton { ModalManager.end.showModal { val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } @@ -391,20 +532,13 @@ fun ModalData.GroupChatInfoLayout( } } } + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs - SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) - SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) { + SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { if (groupInfo.canAddMembers) { - if (groupInfo.businessChat == null) { - if (groupLink == null) { - CreateGroupLinkButton(manageGroupLink) - } else { - GroupLinkButton(manageGroupLink) - } - } val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary val addMembersTitleId = when (groupInfo.businessChat?.chatType) { @@ -414,9 +548,9 @@ fun ModalData.GroupChatInfoLayout( } AddMembersButton(addMembersTitleId, tint, onAddMembersClick) } - if (members.size > 8) { + if (activeSortedMembers.size > 8) { SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { - SearchRowView(searchText) + MemberListSearchRowView(searchText) } } SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { @@ -424,12 +558,34 @@ fun ModalData.GroupChatInfoLayout( } } } - items(filteredMembers.value) { member -> + items(filteredMembers.value, key = { it.groupMemberId }) { member -> Divider() val showMenu = remember { mutableStateOf(false) } - SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { - DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) - MemberRow(member, onClick = { showMemberInfo(member) }) + val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator + SectionItemViewLongClickable( + click = { + if (selectedItems.value != null) { + if (canBeSelected) { + toggleItemSelection(member.groupMemberId, selectedItems) + } + } else { + showMemberInfo(member) + } + }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + Box(contentAlignment = Alignment.CenterStart) { + androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.alpha(if (canBeSelected) 1f else 0f).padding(start = 2.dp), member.groupMemberId, selectedItems) + } + val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp) + DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu) + Box(Modifier.padding(start = selectionOffset)) { + MemberRow(member) + } + } } } item { @@ -440,7 +596,7 @@ fun ModalData.GroupChatInfoLayout( val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat DeleteGroupButton(titleId, deleteGroup) } - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat LeaveGroupButton(titleId, leaveGroup) } @@ -454,12 +610,109 @@ fun ModalData.GroupChatInfoLayout( } } SectionBottomSpacer() - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } } if (!oneHandUI.value) { NavigationBarBackground(oneHandUI.value, oneHandUI.value) } + SelectedItemsButtonsToolbar(chat, groupInfo, selectedItems, rememberUpdatedState(activeSortedMembers)) + SelectedItemsCounterToolbarSetter(groupInfo, selectedItems, filteredMembers, appBar) + } +} + +@Composable +private fun BoxScope.SelectedItemsButtonsToolbar(chat: Chat, groupInfo: GroupInfo, selectedItems: MutableState?>, activeMembers: State>) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + Column(Modifier.align(Alignment.BottomCenter)) { + AnimatedVisibility(selectedItems.value != null) { + SelectedItemsMembersToolbar( + selectedItems = selectedItems, + activeMembers = activeMembers, + groupInfo = groupInfo, + delete = { + removeMembersAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + }, + blockForAll = { block -> + if (block) { + blockForAllAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + } else { + unblockForAllAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + } + }, + changeRole = { toRole -> + updateMembersRoleDialog(toRole, groupInfo) { + updateMembersRole(toRole, chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + } + } + ) + } + if (oneHandUI.value) { + // That's placeholder to take some space for bottom app bar in oneHandUI + Box(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier)) + } + } +} + +@Composable +private fun SelectedItemsCounterToolbarSetter( + groupInfo: GroupInfo, + selectedItems: MutableState?>, + filteredMembers: State>, + appBar: MutableState<@Composable (BoxScope.() -> Unit)?> +) { + LaunchedEffect( + groupInfo, + /* variable, not value - intentionally - to reduce work but handle variable change because it changes in remember(members) { derivedState {} } */ + filteredMembers + ) { + snapshotFlow { selectedItems.value == null } + .collect { nullItems -> + if (!nullItems) { + appBar.value = { + SelectedItemsCounterToolbar(selectedItems, !remember { appPrefs.oneHandUI.state }.value) { + if (!groupInfo.membership.memberActive) return@SelectedItemsCounterToolbar + val ids: MutableSet = mutableSetOf() + for (mem in filteredMembers.value) { + if (groupInfo.membership.memberActive && groupInfo.membership.memberRole >= mem.memberRole && mem.memberRole < GroupMemberRole.Moderator) { + ids.add(mem.groupMemberId) + } + } + if (ids.isNotEmpty() && (selectedItems.value ?: setOf()).containsAll(ids)) { + selectedItems.value = (selectedItems.value ?: setOf()).minus(ids) + } else { + selectedItems.value = (selectedItems.value ?: setOf()).union(ids) + } + } + } + } else { + appBar.value = null + } + } + } +} + +@Composable +fun ChatTTLOption(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State) { + Box { + TtlOptions( + chatItemTTL, + enabled = remember { derivedStateOf { !deletingItems.value } }, + onSelected = setChatItemTTL, + default = chatModel.chatItemTTL + ) + if (deletingItems.value) { + Box(Modifier.matchParentSize()) { + ProgressIndicator() + } + } } } @@ -496,6 +749,23 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { } } +@Composable +private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { + SettingsActionItemWithContent( + painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), + stringResource(MR.strings.member_support), + click = onClick, + iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) + ) { + if (chat.supportUnreadCount > 0) { + UnreadBadge( + text = unreadCountStr(chat.supportUnreadCount), + backgroundColor = MaterialTheme.colors.primary + ) + } + } +} + @Composable private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) { SettingsActionItem( @@ -506,12 +776,20 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) } @Composable -private fun GroupReportsButton(onClick: () -> Unit) { - SettingsActionItem( - painterResource(MR.images.ic_flag), +private fun GroupReportsButton(chat: Chat, onClick: () -> Unit) { + SettingsActionItemWithContent( + painterResource(if (chat.chatStats.reportsCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.group_reports_member_reports), - click = onClick - ) + click = onClick, + iconColor = (if (chat.chatStats.reportsCount > 0) Color.Red else MaterialTheme.colors.secondary) + ) { + if (chat.chatStats.reportsCount > 0) { + UnreadBadge( + text = unreadCountStr(chat.chatStats.reportsCount), + backgroundColor = Color.Red + ) + } + } } @Composable @@ -557,14 +835,14 @@ private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialThem } @Composable -private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) { +fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false) { @Composable fun MemberInfo() { if (member.blocked) { Text(stringResource(MR.strings.member_info_member_blocked), color = MaterialTheme.colors.secondary) } else { val role = member.memberRole - if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Observer)) { + if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Moderator, GroupMemberRole.Observer)) { Text(role.text, color = MaterialTheme.colors.secondary) } } @@ -586,11 +864,11 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - verticalAlignment = Alignment.CenterVertically ) { Row( - Modifier.weight(1f).padding(top = 8.dp, end = DEFAULT_PADDING, bottom = 8.dp), + Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - MemberProfileImage(size = 42.dp, member) + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Column { Row(verticalAlignment = Alignment.CenterVertically) { @@ -598,33 +876,48 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - MemberVerifiedShield() } Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } - val statusDescr = - if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() - Text( - statusDescr, - color = MaterialTheme.colors.secondary, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + + if (infoPage) { + val statusDescr = + if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() + Text( + statusDescr, + color = MaterialTheme.colors.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } - MemberInfo() + if (infoPage) { + MemberInfo() + } + if (selected) { + Icon( + painterResource( + MR.images.ic_check + ), + null, + Modifier.size(20.dp), + tint = MaterialTheme.colors.primary, + ) + } } } @Composable -private fun MemberVerifiedShield() { +fun MemberVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary) } @Composable -private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { - if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { +private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, selectedItems: MutableState?>, showMenu: MutableState) { + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { val canBlockForAll = member.canBlockForAll(groupInfo) val canRemove = member.canBeRemoved(groupInfo) if (canBlockForAll || canRemove) { @@ -648,6 +941,10 @@ private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: G showMenu.value = false }) } + if (selectedItems.value == null && member.memberRole < GroupMemberRole.Moderator) { + Divider() + SelectItemAction(showMenu) { toggleItemSelection(member.groupMemberId, selectedItems) } + } } } } else if (!member.blockedByAdmin) { @@ -735,7 +1032,7 @@ private fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { } @Composable -private fun SearchRowView( +fun MemberListSearchRowView( searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } ) { Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) { @@ -750,12 +1047,45 @@ 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) } } } +fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { + withBGApi { + val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) + if (r != null) { + val (updatedGroupInfo, updatedMembers) = r + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) + updatedMembers.forEach { updatedMember -> + chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + } + } + withContext(Dispatchers.Main) { + updatedMembers.forEach { updatedMember -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + } + } + onSuccess() + } + } +} + +fun toggleItemSelection(itemId: T, selectedItems: MutableState?>) { + val select = selectedItems.value?.contains(itemId) != true + if (select) { + val sel = selectedItems.value ?: setOf() + selectedItems.value = sel + itemId + } else { + val sel = (selectedItems.value ?: setOf()).toMutableSet() + sel.remove(itemId) + selectedItems.value = sel + } +} + @Preview @Composable fun PreviewGroupChatInfoLayout() { @@ -770,12 +1100,27 @@ fun PreviewGroupChatInfoLayout() { User.sampleData, sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, setSendReceipts = {}, - members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), + chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, + setChatItemTTL = {}, + activeSortedMembers = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, onLocalAliasChanged = {}, groupLink = null, + selectedItems = remember { mutableStateOf(null) }, + appBar = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) }, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, + addMembers = {}, + showMemberInfo = {}, + editGroupProfile = {}, + addOrEditWelcomeMessage = {}, + openMemberSupport = {}, + openPreferences = {}, + deleteGroup = {}, + clearChat = {}, + leaveGroup = {}, + manageGroupLink = {}, + onSearchClicked = {}, + deletingItems = remember { mutableStateOf(true) } ) } } 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 956ee575de..30c16db6a4 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,9 +1,9 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer +import SectionViewWithButton +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -16,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 @@ -28,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() { @@ -49,6 +49,18 @@ fun GroupLinkView( creatingLink = false } } + fun addShortLink() { + creatingLink = true + withBGApi { + val link = chatModel.controller.apiAddGroupShortLink(rhId, groupInfo.groupId) + if (link != null) { + groupLink = link.first + groupLinkMemberRole.value = link.second + onGroupLinkUpdated?.invoke(link) + } + creatingLink = false + } + } LaunchedEffect(Unit) { if (groupLink == null && !creatingLink) { createLink() @@ -60,6 +72,7 @@ fun GroupLinkView( groupLinkMemberRole, creatingLink, createLink = ::createLink, + addShortLink = ::addShortLink, updateLink = { val role = groupLinkMemberRole.value if (role != null) { @@ -100,11 +113,12 @@ fun GroupLinkView( @Composable fun GroupLinkLayout( - groupLink: String?, + groupLink: CreatedConnLink?, groupInfo: GroupInfo, groupLinkMemberRole: MutableState, creatingLink: Boolean, createLink: () -> Unit, + addShortLink: () -> Unit, updateLink: () -> Unit, deleteLink: () -> Unit, creatingGroup: Boolean = false, @@ -151,7 +165,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, @@ -161,7 +183,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) @@ -174,12 +196,26 @@ fun GroupLinkLayout( ) } } + if (groupLink.connShortLink == null && appPreferences.privacyShortLinks.get()) { + AddShortLinkButton(addShortLink) + } } } SectionBottomSpacer() } } +@Composable +private fun AddShortLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.add_short_link), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState, enabled: Boolean = true) { Row( 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 ef1c69a5bb..e56bc36562 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,12 +38,14 @@ 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( rhId: Long?, groupInfo: GroupInfo, member: GroupMember, + scrollToItemId: MutableState, connectionStats: ConnectionStats?, connectionCode: String?, chatModel: ChatModel, @@ -63,11 +63,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() } @@ -80,6 +80,7 @@ fun GroupMemberInfoView( rhId = rhId, groupInfo, member, + scrollToItemId, connStats, newRole, developerTools, @@ -87,7 +88,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 +101,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() @@ -137,22 +138,10 @@ fun GroupMemberInfoView( if (it == newRole.value) return@GroupMemberInfoLayout val prevValue = newRole.value newRole.value = it - updateMemberRoleDialog(it, groupInfo, member, onDismiss = { + updateMemberRoleDialog(it, groupInfo, member.memberCurrent, onDismiss = { newRole.value = prevValue }) { - withBGApi { - kotlin.runCatching { - val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it) - withChats { - upsertGroupMember(rhId, groupInfo, mem) - } - withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, mem) - } - }.onFailure { - newRole.value = prevValue - } - } + updateMembersRole(newRole.value, rhId, groupInfo, listOf(member.groupMemberId), onFailure = { newRole.value = prevValue }) } }, switchMemberAddress = { @@ -161,11 +150,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() } @@ -178,11 +167,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() } @@ -198,11 +187,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() } @@ -224,11 +213,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 } @@ -256,28 +245,34 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c text = generalGetString(messageId), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { - withBGApi { - val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId) - if (removedMember != null) { - withChats { - upsertGroupMember(rhId, groupInfo, removedMember) - } - withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, removedMember) - } - } - close?.invoke() - } + removeMember(rhId, member, chatModel, close) }, destructive = true, ) } +fun removeMember(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { + withBGApi { + val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + if (r != null) { + val (updatedGroupInfo, removedMembers) = r + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) + removedMembers.forEach { removedMember -> + chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) + } + } + } + close?.invoke() + } +} + @Composable fun GroupMemberInfoLayout( rhId: Long?, groupInfo: GroupInfo, member: GroupMember, + scrollToItemId: MutableState, connStats: MutableState, newRole: MutableState, developerTools: Boolean, @@ -309,7 +304,30 @@ fun GroupMemberInfoLayout( } @Composable - fun AdminDestructiveSection() { + fun SupportChatButton() { + val scope = rememberCoroutineScope() + + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.button_support_chat_member), + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + iconColor = MaterialTheme.colors.secondary, + ) + } + + @Composable + fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) val canRemove = member.canBeRemoved(groupInfo) if (canBlockForAll || canRemove) { @@ -422,6 +440,12 @@ fun GroupMemberInfoLayout( if (member.memberActive) { SectionView { + if ( + groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) + ) { + SupportChatButton() + } if (connectionCode != null) { VerifyCodeButton(member.verified, verifyClicked) } @@ -486,8 +510,8 @@ fun GroupMemberInfoLayout( } } - if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { - AdminDestructiveSection() + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ModeratorDestructiveSection() } else { NonAdminBlockSection() } @@ -701,16 +725,37 @@ fun MemberProfileImage( ) } -private fun updateMemberRoleDialog( +fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInfo, memberIds: List, onFailure: () -> Unit = {}, onSuccess: () -> Unit = {}) { + withBGApi { + kotlin.runCatching { + val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, memberIds, newRole) + withContext(Dispatchers.Main) { + members.forEach { member -> + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, member) + } + } + withContext(Dispatchers.Main) { + members.forEach { member -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, member) + } + } + onSuccess() + }.onFailure { + onFailure() + } + } +} + +fun updateMemberRoleDialog( newRole: GroupMemberRole, groupInfo: GroupInfo, - member: GroupMember, + memberCurrent: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit ) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.change_member_role_question), - text = if (member.memberCurrent) { + text = if (memberCurrent) { if (groupInfo.businessChat == null) String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text) else @@ -724,6 +769,22 @@ private fun updateMemberRoleDialog( ) } +fun updateMembersRoleDialog( + newRole: GroupMemberRole, + groupInfo: GroupInfo, + onConfirm: () -> Unit +) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_member_role_question), + text = if (groupInfo.businessChat == null) + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text) + else + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text), + confirmText = generalGetString(MR.strings.change_verb), + onConfirm = onConfirm, + ) +} + fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { try { withBGApi { @@ -769,11 +830,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)) } } } @@ -785,7 +846,19 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, true) + blockMemberForAll(rhId, gInfo, listOf(mem.groupMemberId), true) + }, + destructive = true, + ) +} + +fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.block_members_for_all_question), + text = generalGetString(MR.strings.block_members_desc), + confirmText = generalGetString(MR.strings.block_for_all), + onConfirm = { + blockMemberForAll(rhId, gInfo, memberIds, true, onSuccess) }, destructive = true, ) @@ -797,20 +870,36 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, false) + blockMemberForAll(rhId, gInfo, listOf(mem.groupMemberId), false) }, ) } -fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { +fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unblock_members_for_all_question), + text = generalGetString(MR.strings.unblock_members_desc), + confirmText = generalGetString(MR.strings.unblock_for_all), + onConfirm = { + blockMemberForAll(rhId, gInfo, memberIds, false, onSuccess) + }, + ) +} + +fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, memberIds: List, blocked: Boolean, onSuccess: () -> Unit = {}) { withBGApi { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) - withChats { - upsertGroupMember(rhId, gInfo, updatedMember) + val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, memberIds, blocked) + withContext(Dispatchers.Main) { + updatedMembers.forEach { updatedMember -> + chatModel.chatsContext.upsertGroupMember(rhId, gInfo, updatedMember) + } } - withReportsChatsIfOpen { - upsertGroupMember(rhId, gInfo, updatedMember) + withContext(Dispatchers.Main) { + updatedMembers.forEach { updatedMember -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, gInfo, updatedMember) + } } + onSuccess() } } @@ -822,6 +911,7 @@ fun PreviewGroupMemberInfoLayout() { rhId = null, groupInfo = GroupInfo.sampleData, member = GroupMember.sampleData, + scrollToItemId = remember { mutableStateOf(null) }, connStats = remember { mutableStateOf(null) }, newRole = remember { mutableStateOf(GroupMemberRole.Member) }, developerTools = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt new file mode 100644 index 0000000000..62f1a4337c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -0,0 +1,129 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.focus.FocusRequester +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +fun SelectedItemsMembersToolbar( + selectedItems: MutableState?>, + activeMembers: State>, + groupInfo: GroupInfo, + delete: () -> Unit, + blockForAll: (Boolean) -> Unit, // Boolean - block or unlock + changeRole: (GroupMemberRole) -> Unit, +) { + val deleteEnabled = remember { mutableStateOf(false) } + val blockForAllEnabled = remember { mutableStateOf(false) } + val unblockForAllEnabled = remember { mutableStateOf(false) } + val blockForAllButtonEnabled = remember { derivedStateOf { (blockForAllEnabled.value && !unblockForAllEnabled.value) || (!blockForAllEnabled.value && unblockForAllEnabled.value) } } + + val roleToMemberEnabled = remember { mutableStateOf(false) } + val roleToObserverEnabled = remember { mutableStateOf(false) } + val roleButtonEnabled = remember { derivedStateOf { (roleToMemberEnabled.value && !roleToObserverEnabled.value) || (!roleToMemberEnabled.value && roleToObserverEnabled.value) } } + Box( + Modifier + .background(MaterialTheme.colors.background) + .navigationBarsPadding() + .imePadding() + ) { + // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty + Box(Modifier.alpha(0f)) { + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) + } + Row( + Modifier + .matchParentSize() + .padding(horizontal = 2.dp) + .height(AppBarHeight * fontSizeSqrtMultiplier) + .pointerInput(Unit) { + detectGesture { + true + } + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(delete, enabled = deleteEnabled.value) { + Icon( + painterResource(MR.images.ic_delete), + null, + Modifier.size(22.dp), + tint = if (!deleteEnabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error + ) + } + + IconButton({ blockForAll(blockForAllEnabled.value) }, enabled = blockForAllButtonEnabled.value) { + Icon( + painterResource(if (unblockForAllEnabled.value && blockForAllButtonEnabled.value) MR.images.ic_do_not_touch else MR.images.ic_back_hand), + null, + Modifier.size(22.dp), + tint = if (!blockForAllButtonEnabled.value) MaterialTheme.colors.secondary else if (blockForAllEnabled.value) MaterialTheme.colors.error else WarningOrange + ) + } + + IconButton({ changeRole(if (roleToMemberEnabled.value) GroupMemberRole.Member else GroupMemberRole.Observer) }, enabled = roleButtonEnabled.value) { + Icon( + painterResource(if (roleToObserverEnabled.value || !roleButtonEnabled.value) MR.images.ic_person else MR.images.ic_person_edit), + null, + Modifier.size(22.dp), + tint = if (!roleButtonEnabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + Divider(Modifier.align(Alignment.TopStart)) + } + LaunchedEffect(groupInfo, activeMembers.value.toList(), selectedItems.value) { + recheckItems(groupInfo, selectedItems, activeMembers.value, deleteEnabled, blockForAllEnabled, unblockForAllEnabled, roleToMemberEnabled, roleToObserverEnabled) + } +} + +private fun recheckItems( + groupInfo: GroupInfo, + selectedItems: MutableState?>, + activeMembers: List, + deleteEnabled: MutableState, + blockForAllEnabled: MutableState, + unblockForAllEnabled: MutableState, + roleToMemberEnabled: MutableState, + roleToObserverEnabled: MutableState, +) { + val selected = selectedItems.value ?: return + var rDeleteEnabled = true + var rBlockForAllEnabled = true + var rUnblockForAllEnabled = true + var rRoleToMemberEnabled = true + var rRoleToObserverEnabled = true + val rSelectedItems = mutableSetOf() + for (mem in activeMembers) { + if (selected.contains(mem.groupMemberId) && groupInfo.membership.memberRole >= mem.memberRole && mem.memberRole < GroupMemberRole.Moderator && groupInfo.membership.memberActive) { + rDeleteEnabled = rDeleteEnabled && mem.memberStatus != GroupMemberStatus.MemRemoved && mem.memberStatus != GroupMemberStatus.MemLeft + rBlockForAllEnabled = rBlockForAllEnabled && !mem.blockedByAdmin + rUnblockForAllEnabled = rUnblockForAllEnabled && mem.blockedByAdmin + rRoleToMemberEnabled = rRoleToMemberEnabled && mem.memberRole != GroupMemberRole.Member + rRoleToObserverEnabled = rRoleToObserverEnabled && mem.memberRole != GroupMemberRole.Observer + rSelectedItems.add(mem.groupMemberId) // we are collecting new selected items here to account for any changes in members list + } + } + deleteEnabled.value = rDeleteEnabled + blockForAllEnabled.value = rBlockForAllEnabled + unblockForAllEnabled.value = rUnblockForAllEnabled + roleToMemberEnabled.value = rRoleToMemberEnabled + roleToObserverEnabled.value = rRoleToObserverEnabled + selectedItems.value = rSelectedItems +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt new file mode 100644 index 0000000000..aa737a02d3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -0,0 +1,326 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +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.focus.FocusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.launch +import kotlin.text.CharCategory.* + +val punctuation = setOf( + DASH_PUNCTUATION, START_PUNCTUATION, END_PUNCTUATION, + CONNECTOR_PUNCTUATION, OTHER_PUNCTUATION +) + +private val PICKER_ROW_SIZE = MEMBER_ROW_AVATAR_SIZE + (MEMBER_ROW_VERTICAL_PADDING * 2f) +private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE + MEMBER_ROW_VERTICAL_PADDING - 4.dp) + +@Composable +fun GroupMentions( + chatsCtx: ChatModel.ChatsContext, + rhId: Long?, + composeState: MutableState, + composeViewFocusRequester: FocusRequester?, + chatInfo: ChatInfo.Group +) { + val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } + val isVisible = remember { mutableStateOf(false) } + val offsetY = remember { Animatable(maxHeightInPx) } + + val currentMessage = remember { mutableStateOf(composeState.value.message) } + val mentionName = remember { mutableStateOf("") } + val mentionRange = remember { mutableStateOf(null) } + val mentionMemberId = remember { mutableStateOf(null) } + + fun contextMemberFilter(member: GroupMember): Boolean = + when (chatsCtx.secondaryContextFilter) { + null -> true + is SecondaryContextFilter.GroupChatScopeContext -> + when (chatsCtx.secondaryContextFilter.groupScopeInfo) { + is GroupChatScopeInfo.MemberSupport -> { + val scopeMember = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ + if (scopeMember != null) { + member.memberRole >= GroupMemberRole.Moderator || member.groupMemberId == scopeMember.groupMemberId + } else { + member.memberRole >= GroupMemberRole.Moderator + } + } + } + is SecondaryContextFilter.MsgContentTagContext -> false + } + + val filteredMembers = remember { + derivedStateOf { + val members = chatModel.groupMembers.value + .filter { + val status = it.memberStatus + status != GroupMemberStatus.MemLeft && status != GroupMemberStatus.MemRemoved && status != GroupMemberStatus.MemInvited + && contextMemberFilter(it) + } + .sortedByDescending { it.memberRole } + + if (mentionName.value.isEmpty()) { + members + } else { + members.filter { it.memberProfile.anyNameContains(mentionName.value) } + } + } + } + val scope = rememberCoroutineScope() + + suspend fun closeMembersPicker() { + isVisible.value = false + if (offsetY.value != 0f) { + return + } + + offsetY.animateTo( + targetValue = maxHeightInPx, + animationSpec = mentionPickerAnimSpec() + ) + mentionName.value = "" + mentionRange.value = null + mentionMemberId.value = null + } + + fun messageChanged(msg: ComposeMessage, parsedMsg: List) { + removeUnusedMentions(composeState, parsedMsg) + val selected = selectedMarkdown(parsedMsg, msg.selection) + + if (selected != null) { + val (ft, r) = selected + + when (ft.format) { + is Format.Mention -> { + isVisible.value = true + mentionName.value = ft.format.memberName + mentionRange.value = r + mentionMemberId.value = composeState.value.mentions[mentionName.value]?.memberId + if (!chatModel.membersLoaded.value) { + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + } + return + } + null -> { + val pos = msg.selection.start + if (msg.selection.length == 0 && getCharacter(msg.text, pos - 1)?.first == "@") { + val prevChar = getCharacter(msg.text, pos - 2)?.first + if (prevChar == null || prevChar == " " || prevChar == "\n") { + isVisible.value = true + mentionName.value = "" + mentionRange.value = TextRange(pos - 1, pos) + mentionMemberId.value = null + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + return + } + } + } + else -> {} + } + } + scope.launch { + closeMembersPicker() + } + } + + fun addMemberMention(member: GroupMember, range: TextRange) { + val mentions = composeState.value.mentions.toMutableMap() + val existingMention = mentions.entries.firstOrNull { + it.value.memberId == member.memberId + } + val newName = existingMention?.key ?: composeState.value.mentionMemberName(member.memberProfile.displayName) + mentions[newName] = CIMention(member) + var msgMention = if (newName.contains(" ") || (newName.lastOrNull()?.category in punctuation)) + "@'$newName'" + else "@$newName" + var newPos = range.start + msgMention.length + val newMsgLength = composeState.value.message.text.length + msgMention.length - range.length + if (newPos == newMsgLength) { + msgMention += " " + newPos += 1 + } + + val msg = composeState.value.message.text.replaceRange( + range.start, + range.end, + msgMention + ) + composeState.value = composeState.value.copy( + message = ComposeMessage(msg, TextRange(newPos)), + parsedMessage = parseToMarkdown(msg) ?: FormattedText.plain(msg), + mentions = mentions + ) + + composeViewFocusRequester?.requestFocus() + + scope.launch { + closeMembersPicker() + } + } + + LaunchedEffect(composeState.value.parsedMessage) { + currentMessage.value = composeState.value.message + messageChanged(currentMessage.value, composeState.value.parsedMessage) + } + +// KeyChangeEffect(composeState.value.message.selection) { +// // This condition is needed to prevent messageChanged called twice, +// // because composeState.formattedText triggers later when message changes. +// // The condition is only true if position changed without text change +// if (currentMessage.value.text == composeState.value.message.text) { +// messageChanged(currentMessage.value, composeState.value.parsedMessage) +// } +// } + + LaunchedEffect(isVisible.value) { + if (isVisible.value) { + offsetY.animateTo( + targetValue = 0f, + animationSpec = mentionPickerAnimSpec() + ) + } + } + Box( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, offsetY.value.toInt()) } + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + scope.launch { closeMembersPicker() } + }, + contentAlignment = Alignment.BottomStart + ) { + val showMaxReachedBox = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && isVisible.value && composeState.value.mentions[mentionName.value] == null + LazyColumnWithScrollBarNoAppBar( + Modifier + .heightIn(max = MAX_PICKER_HEIGHT) + .background(MaterialTheme.colors.surface), + maxHeight = remember { mutableStateOf(MAX_PICKER_HEIGHT) }, + containerAlignment = Alignment.BottomEnd + ) { + if (showMaxReachedBox) { + stickyHeader { + MaxMentionsReached() + } + } + itemsIndexed(filteredMembers.value, key = { _, item -> item.groupMemberId }) { i, member -> + if (i != 0 || !showMaxReachedBox) { + Divider() + } + val mentioned = mentionMemberId.value == member.memberId + val disabled = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && !mentioned + Row( + Modifier + .fillMaxWidth() + .alpha(if (disabled) 0.6f else 1f) + .clickable(enabled = !disabled) { + val range = mentionRange.value ?: return@clickable + val mentionMemberValue = mentionMemberId.value + + if (mentionMemberValue != null) { + if (mentionMemberValue != member.memberId) { + addMemberMention(member, range) + } else { + return@clickable + } + } else { + addMemberMention(member, range) + } + } + .padding(horizontal = DEFAULT_PADDING_HALF), + verticalAlignment = Alignment.CenterVertically + ) { + MemberRow( + member, + infoPage = false, + showlocalAliasAndFullName = true, + selected = mentioned + ) + } + } + } + } +} + +@Composable +private fun MaxMentionsReached() { + Column(Modifier.background(MaterialTheme.colors.surface)) { + Divider() + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + String.format(generalGetString(MR.strings.max_group_mentions_per_message_reached), MAX_NUMBER_OF_MENTIONS), + Modifier.padding(12.dp), + ) + } + Divider() + } +} + +private fun getCharacter(s: String, pos: Int): Pair? { + return if (pos in s.indices) { + val char = s.subSequence(pos, pos + 1) + char to (pos until pos + 1) + } else { + null + } +} + +private fun selectedMarkdown( + parsedMsg: List, + range: TextRange +): Pair? { + if (parsedMsg.isEmpty()) return null + + var i = 0 + var pos = 0 + + while (i < parsedMsg.size && pos + parsedMsg[i].text.length < range.start) { + pos += parsedMsg[i].text.length + i++ + } + + return if (i >= parsedMsg.size || range.end > pos + parsedMsg[i].text.length) { + null + } else { + parsedMsg[i] to TextRange(pos, pos + parsedMsg[i].text.length) + } +} + +private fun removeUnusedMentions(composeState: MutableState, parsedMsg: List) { + val usedMentions = parsedMsg.mapNotNull { ft -> + when (ft.format) { + is Format.Mention -> ft.format.memberName + else -> null + } + }.toSet() + + if (usedMentions.size < composeState.value.mentions.size) { + composeState.value = composeState.value.copy( + mentions = composeState.value.mentions.filterKeys { it in usedMentions } + ) + } +} 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 3d9f42f929..b8db5969a1 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,12 +15,16 @@ 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.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* private val featureRoles: List> = listOf( null to generalGetString(MR.strings.feature_roles_all_members), + GroupMemberRole.Moderator to generalGetString(MR.strings.feature_roles_moderators), GroupMemberRole.Admin to generalGetString(MR.strings.feature_roles_admins), GroupMemberRole.Owner to generalGetString(MR.strings.feature_roles_owners) ) @@ -41,12 +45,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() @@ -69,6 +73,16 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> preferences = currentPreferences }, savePrefs = ::savePrefs, + openMemberAdmission = { + ModalManager.end.showCustomModal { close -> + MemberAdmissionView( + chatModel, + rhId, + chatId, + close + ) + } + } ) } } @@ -81,10 +95,15 @@ private fun GroupPreferencesLayout( applyPrefs: (FullGroupPreferences) -> Unit, reset: () -> Unit, savePrefs: () -> Unit, + openMemberAdmission: () -> Unit, ) { ColumnWithScrollBar { val titleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences AppBarTitle(stringResource(titleId)) + if (groupInfo.businessChat == null) { + MemberAdmissionButton(openMemberAdmission) + SectionDividerSpaced(maxBottomPadding = false) + } val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) } val onTTLUpdated = { ttl: Int? -> applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl))) @@ -132,6 +151,11 @@ private fun GroupPreferencesLayout( applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role))) } + SectionDividerSpaced(true, maxBottomPadding = false) + val enableReports = remember(preferences) { mutableStateOf(preferences.reports.enable) } + FeatureSection(GroupFeature.Reports, enableReports, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(reports = GroupPreference(enable = enable))) + } SectionDividerSpaced(true, maxBottomPadding = false) val enableHistory = remember(preferences) { mutableStateOf(preferences.history.enable) } FeatureSection(GroupFeature.History, enableHistory, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> @@ -149,6 +173,15 @@ private fun GroupPreferencesLayout( } } +@Composable +private fun MemberAdmissionButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.member_admission), + click = onClick + ) +} + @Composable private fun FeatureSection( feature: GroupFeature, @@ -169,6 +202,7 @@ private fun FeatureSection( feature.text, icon, iconTint, + disabled = feature == GroupFeature.Reports, // remove in 6.4 checked = enableFeature.value == GroupFeatureEnabled.ON, ) { checked -> onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value) 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 a1ec3ec0a9..2cc2402c0a 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,30 +14,22 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.* -val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } - -data class GroupReports( - val reportsCount: Int, - val reportsView: Boolean, -) { - val showBar: Boolean = reportsCount > 0 && !reportsView - - fun toContentTag(): MsgContentTag? { - if (!reportsView) return null - return MsgContentTag.Report - } - - val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report -} - @Composable -private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { - ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {}) +private fun GroupReportsView( + reportsChatsCtx: ChatModel.ChatsContext, + staleChatId: State, + scrollToItemId: MutableState, + close: () -> Unit +) { + KeyChangeEffect(chatModel.chatId.value) { + close() + } + ChatView(reportsChatsCtx, staleChatId, scrollToItemId, onComposed = {}) } @Composable fun GroupReportsAppBar( - groupReports: State, + chatsCtx: ChatModel.ChatsContext, close: () -> Unit, onSearchValueChanged: (String) -> Unit ) { @@ -65,11 +57,11 @@ fun GroupReportsAppBar( } } ) - ItemsReload(groupReports) + ItemsReload(chatsCtx) } @Composable -private fun ItemsReload(groupReports: State) { +fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { LaunchedEffect(Unit) { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() @@ -79,18 +71,19 @@ private fun ItemsReload(groupReports: State) { .filterNotNull() .filter { it.chatInfo is ChatInfo.Group } .collect { chat -> - reloadItems(chat, groupReports) + 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(secondaryContextFilter = SecondaryContextFilter.MsgContentTagContext(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, close) } else { LaunchedEffect(Unit) { close() @@ -100,7 +93,6 @@ suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: Mu } } -private suspend fun reloadItems(chat: Chat, groupReports: State) { - val contentFilter = groupReports.value.toContentTag() - apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, 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/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt new file mode 100644 index 0000000000..48171bfeb7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -0,0 +1,152 @@ +package chat.simplex.common.views.chat.group + +import InfoRow +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit) { + val groupInfo = remember { derivedStateOf { + val ch = m.getChat(chatId) + val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo + if (g == null || ch.remoteHostId != rhId) null else g + }} + val gInfo = groupInfo.value ?: return + var admission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.groupProfile.memberAdmission) } + var currentAdmission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(admission) } + + fun saveAdmission(afterSave: () -> Unit = {}) { + withBGApi { + val gp = gInfo.groupProfile.copy(memberAdmission = admission) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) + if (g != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, g) + currentAdmission = admission + } + } + afterSave() + } + } + ModalView( + close = { + if (admission == currentAdmission) close() + else showUnsavedChangesAlert({ saveAdmission(close) }, close) + }, + ) { + MemberAdmissionLayout( + admission, + currentAdmission, + gInfo, + applyAdmission = { admsn -> + admission = admsn + }, + reset = { + admission = currentAdmission + }, + saveAdmission = ::saveAdmission, + ) + } +} + +@Composable +private fun MemberAdmissionLayout( + admission: GroupMemberAdmission?, + currentAdmission: GroupMemberAdmission?, + groupInfo: GroupInfo, + applyAdmission: (GroupMemberAdmission) -> Unit, + reset: () -> Unit, + saveAdmission: () -> Unit, +) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.member_admission)) + val review = remember(admission) { mutableStateOf(admission?.review) } + AdmissionSection(MR.strings.admission_stage_review, MR.strings.admission_stage_review_descr, review, groupInfo) { criteria -> + if (admission != null) { + applyAdmission(admission.copy(review = criteria)) + } else { + applyAdmission(GroupMemberAdmission(review = criteria)) + } + } + if (groupInfo.isOwner) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + ResetSaveButtons( + reset = reset, + save = saveAdmission, + disabled = admission == currentAdmission + ) + } + SectionBottomSpacer() + } +} + +private val memberCriterias: List> = listOf( + null to generalGetString(MR.strings.member_criteria_off), + MemberCriteria.All to generalGetString(MR.strings.member_criteria_all) +) + +@Composable +private fun AdmissionSection( + admissionStageStrId: StringResource, + admissionStageDescrStrId: StringResource, + memberCriteria: State, + groupInfo: GroupInfo, + onSelected: (MemberCriteria?) -> Unit +) { + SectionView { + if (groupInfo.isOwner) { + ExposedDropDownSettingRow( + generalGetString(admissionStageStrId), + memberCriterias, + memberCriteria, + onSelected = { value -> + onSelected(value) + } + ) + } else { + InfoRow( + stringResource(admissionStageStrId), + memberCriteria.value?.text ?: generalGetString(MR.strings.member_criteria_off) + ) + } + } + SectionTextFooter(stringResource( admissionStageDescrStrId)) +} + +@Composable +private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { + SectionView { + SectionItemView(reset, disabled = disabled) { + Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + } + SectionItemView(save, disabled = disabled) { + Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + } + } +} + +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.save_admission_question), + confirmText = generalGetString(MR.strings.save_and_notify_group_members), + dismissText = generalGetString(MR.strings.exit_without_saving), + onConfirm = save, + onDismiss = revert, + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt new file mode 100644 index 0000000000..99e2e3198e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -0,0 +1,145 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +private fun MemberSupportChatView( + chatInfo: ChatInfo, + memberSupportChatsCtx: ChatModel.ChatsContext, + staleChatId: State, + scrollToItemId: MutableState +) { + KeyChangeEffect(chatModel.chatId.value) { + ModalManager.end.closeModals() + } + if (appPlatform.isAndroid) { + DisposableEffect(Unit) { + onDispose { + val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatInfo.id } + if ( + memberSupportChatsCtx.isUserSupportChat + && chat?.chatInfo?.groupInfo_?.membership?.memberPending == true + ) { + withBGApi { + chatModel.chatId.value = null + } + } + } + } + } + ChatView(memberSupportChatsCtx, staleChatId, scrollToItemId, onComposed = {}) +} + +@Composable +fun MemberSupportChatAppBar( + chatsCtx: ChatModel.ChatsContext, + scopeMember_: GroupMember?, + close: () -> Unit, + onSearchValueChanged: (String) -> Unit +) { + val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + val showSearch = rememberSaveable { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch.value) { + close() + } else { + onSearchValueChanged("") + showSearch.value = false + } + } + BackHandler(onBack = onBackClicked) + if (scopeMember_ != null) { + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + title = { MemberSupportChatToolbarTitle(scopeMember_) }, + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + } else { + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + fixedTitleText = stringResource(MR.strings.support_chat), + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + } + ItemsReload(chatsCtx) +} + +@Composable +fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + MemberProfileImage(size = imageSize * fontSizeSqrtMultiplier, member, iconColor) + Column( + Modifier.padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.displayName, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + if (member.fullName != "" && member.fullName != member.displayName && member.localAlias.isEmpty()) { + Text( + member.fullName, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +suspend fun showMemberSupportChatView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo, scopeInfo: GroupChatScopeInfo) { + val memberSupportChatsCtx = ChatModel.ChatsContext(secondaryContextFilter = SecondaryContextFilter.GroupChatScopeContext(scopeInfo)) + openChat(secondaryChatsCtx = memberSupportChatsCtx, chatModel.remoteHostId(), chatInfo) + ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> + ModalView({}, showAppBar = false) { + if (chatInfo is ChatInfo.Group && chatInfo.groupChatScope != null) { + MemberSupportChatView(chatInfo, memberSupportChatsCtx, staleChatId, scrollToItemId) + } else { + LaunchedEffect(Unit) { + close() + } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt new file mode 100644 index 0000000000..298a545c8c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -0,0 +1,302 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewLongClickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +@Composable +fun ModalData.MemberSupportView( + rhId: Long?, + chat: Chat, + groupInfo: GroupInfo, + scrollToItemId: MutableState, + close: () -> Unit +) { + KeyChangeEffect(chatModel.chatId.value) { + ModalManager.end.closeModals() + } + LaunchedEffect(Unit) { + setGroupMembers(rhId, groupInfo, chatModel) + } + ModalView( + close = close, + endButtons = { RefreshMembersButton(rhId, groupInfo) } + ) { + MemberSupportViewLayout( + chat, + groupInfo, + scrollToItemId + ) + } +} + +@Composable +fun RefreshMembersButton( + rhId: Long?, + groupInfo: GroupInfo +) { + IconButton( + onClick = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + } + } + ) { + Icon( + painterResource(MR.images.ic_refresh), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +private fun ModalData.MemberSupportViewLayout( + chat: Chat, + groupInfo: GroupInfo, + scrollToItemId: MutableState +) { + val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + val scope = rememberCoroutineScope() + + val membersWithChats = remember { chatModel.groupMembers }.value + .filter { it.supportChat != null && it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } + .sortedWith( + compareByDescending { it.memberPending } + .thenByDescending { (it.supportChat?.mentions ?: 0) > 0 } + .thenByDescending { (it.supportChat?.memberAttention ?: 0) > 0 } + .thenByDescending { (it.supportChat?.unread ?: 0) > 0 } + .thenByDescending { it.supportChat?.chatTs } + ) + + val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } } + val filteredmembersWithChats = remember(membersWithChats) { + derivedStateOf { + val s = searchText.value.text.trim().lowercase() + if (s.isEmpty()) membersWithChats else membersWithChats.filter { m -> m.anyNameContains(s) } + } + } + + LazyColumnWithScrollBar( + contentPadding = + PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false) + ) + ) { + item { + AppBarTitle(stringResource(MR.strings.member_support)) + } + + if (membersWithChats.isEmpty()) { + item { + Box(Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { + Text(generalGetString(MR.strings.no_support_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + } else { + item { + SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { + MemberListSearchRowView(searchText) + } + } + items(filteredmembersWithChats.value, key = { it.groupMemberId }) { member -> + Divider() + val showMenu = remember { mutableStateOf(false) } + SectionItemViewLongClickable( + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + Box(contentAlignment = Alignment.CenterStart) { + DropDownMenuForSupportChat(chat.remoteHostId, member, groupInfo, showMenu) + SupportChatRow(member) + } + } + } + item { + Divider() + SectionBottomSpacer() + } + } + } +} + +@Composable +fun SupportChatRow(member: GroupMember) { + fun memberStatus(): String { + return if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else if (member.memberPending) { + member.memberStatus.text + } else { + member.memberRole.text + } + } + + @Composable + fun SupportChatUnreadIndicator(supportChat: GroupSupportChat) { + Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { + if (supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0) { + val unreadBadgeColor = when { + supportChat.mentions > 0 || supportChat.memberAttention > 0 -> MaterialTheme.colors.primaryVariant + else -> MaterialTheme.colors.secondary + } + if (supportChat.mentions == 1 && supportChat.unread == 1) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(unreadBadgeColor, shape = CircleShape), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = Color.White, + modifier = Modifier.size(9.sp.toDp()) + ) + } + } else { + if (supportChat.mentions > 0 && supportChat.unread > 1) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = unreadBadgeColor, + modifier = Modifier.size(12.sp.toDp()).offset(y = 2.sp.toDp()) + ) + } + + UnreadBadge( + text = unreadCountStr(supportChat.unread), + backgroundColor = unreadBadgeColor, + yOffset = 2.dp + ) + } + } + } + } + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } + + Text( + memberStatus(), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row { + if (member.memberPending) { + Icon( + painterResource(MR.images.ic_flag_filled), + contentDescription = null, + Modifier.padding(end = 3.dp).size(16.dp), + tint = MaterialTheme.colors.primaryVariant + ) + } + if (member.supportChat != null) { + SupportChatUnreadIndicator(member.supportChat) + } + } + } +} + +@Composable +private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { + DefaultDropdownMenu(showMenu) { + if (member.memberPending) { + ItemAction(stringResource(MR.strings.accept_pending_member_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + acceptMemberDialog(rhId, groupInfo, member) + showMenu.value = false + }) + } else { + ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + deleteMemberSupportChatDialog(rhId, groupInfo, member) + showMenu.value = false + }) + } + } +} + +fun deleteMemberSupportChatDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.delete_member_support_chat_alert_title), + confirmText = generalGetString(MR.strings.delete_member_support_chat_button), + onConfirm = { + deleteMemberSupportChat(rhId, groupInfo, member) + }, + destructive = true, + ) +} + +private fun deleteMemberSupportChat(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + withBGApi { + val r = chatModel.controller.apiDeleteMemberSupportChat(rhId, groupInfo.groupId, member.groupMemberId) + if (r != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) + } + } + } +} 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/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 401d098bea..1be2110b1f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -8,6 +8,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale @@ -91,12 +92,6 @@ fun CIImageView( } } - @Composable - fun imageViewFullWidth(): Dp { - val approximatePadding = 100.dp - return with(LocalDensity.current) { minOf(DEFAULT_MAX_IMAGE_WIDTH, LocalWindowWidth() - approximatePadding) } - } - @Composable fun imageView(imageBitmap: ImageBitmap, onClick: () -> Unit) { Image( @@ -122,7 +117,7 @@ fun CIImageView( // IllegalStateException: Recording currently in progress - missing #endRecording() call? // but can display 5000px image. Using even lower value here just to feel safer. // It happens to WebP because it's not compressed while sending since it can be animated. - if (painter.intrinsicSize.width <= 4320 && painter.intrinsicSize.height <= 4320) { + if (painter.intrinsicSize != Size.Unspecified && painter.intrinsicSize.width <= 4320 && painter.intrinsicSize.height <= 4320) { Image( painter, contentDescription = stringResource(MR.strings.image_descr), @@ -140,7 +135,7 @@ fun CIImageView( ) } else { Box(Modifier - .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) + .width(if (painter.intrinsicSize != Size.Unspecified && painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) .combinedClickable( onLongClick = { showMenu.value = true }, onClick = {} @@ -264,6 +259,12 @@ fun CIImageView( } } +@Composable +fun imageViewFullWidth(): Dp { + val approximatePadding = 100.dp + return with(LocalDensity.current) { minOf(DEFAULT_MAX_IMAGE_WIDTH, LocalWindowWidth() - approximatePadding) } +} + private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted 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 3b2bf63f28..6e938aa5c4 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 @@ -3,8 +3,7 @@ package chat.simplex.common.views.chat.item import SectionItemView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.HoverInteraction -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.* @@ -30,9 +29,10 @@ 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 +import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock import kotlin.math.* @@ -62,6 +62,7 @@ data class ChatItemReactionMenuItem ( @Composable fun ChatItemView( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem, @@ -71,17 +72,21 @@ fun ChatItemView( linkMode: SimplexLinkMode, revealed: State, highlighted: State, + hoveredItemId: MutableState, range: State, selectedChatItems: MutableState?>, + searchIsNotBlank: State, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, + scrollToItemId: MutableState, scrollToQuotedItemFromItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -112,7 +117,7 @@ fun ChatItemView( val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value Box( - modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, + modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier), contentAlignment = alignment, ) { val info = cItem.meta.itemStatus.statusInto @@ -227,190 +232,423 @@ fun ChatItemView( } } - Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { - val interactionSource = remember { MutableInteractionSource() } - val enterInteraction = remember { HoverInteraction.Enter() } - KeyChangeEffect(highlighted.value) { - if (highlighted.value) { - interactionSource.emit(enterInteraction) + @Composable + fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, iconSize: Dp = 22.dp, parentActivated: State, onClick: () -> Unit) { + val buttonInteractionSource = remember { MutableInteractionSource() } + val buttonHovered = buttonInteractionSource.collectIsHoveredAsState() + val buttonPressed = buttonInteractionSource.collectIsPressedAsState() + val buttonActivated = remember { derivedStateOf { buttonHovered.value || buttonPressed.value } } + + val fullyVisible = parentActivated.value || buttonActivated.value || hoveredItemId.value == cItem.id + val mixAlpha = 0.6f + val mixedBackgroundColor = if (fullyVisible) { + if (MaterialTheme.colors.isLight) { + MaterialTheme.colors.secondary.mixWith(Color.White, mixAlpha) } else { - interactionSource.emit(HoverInteraction.Exit(enterInteraction)) + MaterialTheme.colors.secondary.mixWith(Color.Black, mixAlpha) + } + } else { + Color.Unspecified + } + val iconTint = if (fullyVisible) { + Color.White + } else { + if (MaterialTheme.colors.isLight) { + MaterialTheme.colors.secondary.mixWith(Color.White, mixAlpha) + } else { + MaterialTheme.colors.secondary.mixWith(Color.Black, mixAlpha) } } - Column( + + IconButton( + onClick, Modifier - .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = interactionSource, indication = LocalIndication.current) - .onRightClick { showMenu.value = true }, + .padding(start = if (alignStart) 0.dp else DEFAULT_PADDING_HALF + 3.dp, end = if (alignStart) DEFAULT_PADDING_HALF + 3.dp else 0.dp) + .then(if (fullyVisible) Modifier.background(mixedBackgroundColor, CircleShape) else Modifier) + .size(22.dp), + interactionSource = buttonInteractionSource ) { - @Composable - fun framedItemView() { - FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToQuotedItemFromItem) - } + Icon(painterResource(icon), null, Modifier.size(iconSize), tint = iconTint) + } + } - fun deleteMessageQuestionText(): String { - return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) { - generalGetString(MR.strings.delete_message_cannot_be_undone_warning) - } else { - generalGetString(MR.strings.delete_message_mark_deleted_warning) + // improvement could be to track "forwarded from" scope and open it + @Composable + fun GoToItemButton(alignStart: Boolean, parentActivated: State) { + val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId + if (searchIsNotBlank.value) { + GoToItemInnerButton(alignStart, MR.images.ic_search, 17.dp, parentActivated) { + withBGApi { + openChat(secondaryChatsCtx = null, rhId, cInfo.chatType, cInfo.apiId, cItem.id) + closeReportsIfNeeded() } } + } else if (chatTypeApiIdMsgId != null) { + GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, 22.dp, parentActivated) { + val (chatType, apiId, msgId) = chatTypeApiIdMsgId + withBGApi { + openChat(secondaryChatsCtx = null, rhId, chatType, apiId, msgId) + closeReportsIfNeeded() + } + } + } + } - @Composable - fun MsgReactionsMenu() { - val rs = MsgReaction.old.mapNotNull { r -> - if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { - r + Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { + Row(verticalAlignment = Alignment.CenterVertically) { + val bubbleInteractionSource = remember { MutableInteractionSource() } + val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() + if (cItem.chatDir.sent) { + GoToItemButton(true, bubbleHovered) + } + Column(Modifier.weight(1f, fill = false)) { + val enterInteraction = remember { HoverInteraction.Enter() } + LaunchedEffect(highlighted.value, hoveredItemId.value) { + if (highlighted.value || hoveredItemId.value == cItem.id) { + bubbleInteractionSource.emit(enterInteraction) } else { - null + bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction)) } } - if (rs.isNotEmpty()) { - Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { - rs.forEach() { r -> - Box( - Modifier.size(36.dp).clickable { - setReaction(cInfo, cItem, true, r) - showMenu.value = false - }, - contentAlignment = Alignment.Center - ) { - ReactionIcon(r.text, 12.sp) + Column( + Modifier + .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) + .hoverable(bubbleInteractionSource) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { + if (appPlatform.isAndroid && (searchIsNotBlank.value || cItem.meta.itemForwarded?.chatTypeApiIdMsgId != null)) { + hoveredItemId.value = if (hoveredItemId.value == cItem.id) null else cItem.id + } + onClick() + }, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) + .onRightClick { showMenu.value = true }, + ) { + @Composable + fun framedItemView() { + FramedItemView(chatsCtx, cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem) + } + + fun deleteMessageQuestionText(): String { + return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) { + generalGetString(MR.strings.delete_message_cannot_be_undone_warning) + } else { + generalGetString(MR.strings.delete_message_mark_deleted_warning) + } + } + + @Composable + fun MsgReactionsMenu() { + val rs = MsgReaction.supported.mapNotNull { r -> + if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { + r + } else { + null + } + } + if (rs.isNotEmpty()) { + Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + rs.forEach() { r -> + Box( + Modifier.size(36.dp).clip(CircleShape).clickable { + setReaction(cInfo, cItem, true, r) + showMenu.value = false + }, + contentAlignment = Alignment.Center + ) { + ReactionIcon(r.text, 12.sp) + } + } } } } - } - } - @Composable - fun DeleteItemMenu() { - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun MsgContentItemDropdownMenu() { - val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) - when { - // cItem.id check is a special case for live message chat item which has negative ID while not sent yet - cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + @Composable + fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - ArchiveReportItemAction(cItem, showMenu, deleteMessage) - } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { - DefaultDropdownMenu(showMenu) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live && !cItem.localNote) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } - showMenu.value = false - }) - } - val clipboard = LocalClipboardManager.current - val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - - val copyAndShareAllowed = when { - cItem.content.text.isNotEmpty() -> true - cItem.file?.forwardingAllowed() == true -> true - else -> false - } - - if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - var fileSource = getLoadedFileSource(cItem.file) - val shareIfExists = { - when (val f = fileSource) { - null -> clipboard.shareText(cItem.content.text) - else -> shareFile(cItem.text, f) - } - showMenu.value = false - } - if (chatModel.connectedToRemote() && fileSource == null) { - withLongRunningApi(slow = 600_000) { - cItem.file?.loadRemoteFile(true) - fileSource = getLoadedFileSource(cItem.file) - shareIfExists() - } - } else shareIfExists() - }) - } - if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - } - if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { - SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { - ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { - withBGApi { - Log.d(TAG, "ChatItemView downloadFileAction") - val user = chatModel.currentUser.value - if (user != null) { - controller.receiveFile(rhId, user, cItem.file.fileId) - } - } - showMenu.value = false - }) - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) - showMenu.value = false - }) - } - if (cItem.meta.itemDeleted == null && - (cItem.file == null || cItem.file.forwardingAllowed()) && - !cItem.isLiveDummy && !live - ) { - ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { - forwardItem(cInfo, cItem) - showMenu.value = false - }) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) - } - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { - 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) - } - if (cItem.chatDir !is CIDirection.GroupSnd) { - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) - } // else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { - // ReportItemAction(cItem, composeState, showMenu) - // } - } + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) } } } - cItem.meta.itemDeleted != null -> { + + @Composable + fun MsgContentItemDropdownMenu() { + val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) + when { + // cItem.id check is a special case for live message chat item which has negative ID while not sent yet + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) + } + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { + DefaultDropdownMenu(showMenu) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() + } + if (cItem.meta.itemDeleted == null && !live && !cItem.localNote) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + val clipboard = LocalClipboardManager.current + val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } + val copyAndShareAllowed = when { + cItem.content.text.isNotEmpty() -> true + cItem.file?.forwardingAllowed() == true -> true + else -> false + } + + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + var fileSource = getLoadedFileSource(cItem.file) + val shareIfExists = { + when (val f = fileSource) { + null -> clipboard.shareText(cItem.content.text) + else -> shareFile(cItem.text, f) + } + showMenu.value = false + } + if (chatModel.connectedToRemote() && fileSource == null) { + withLongRunningApi(slow = 600_000) { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + shareIfExists() + } + } else shareIfExists() + }) + } + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + } + if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { + SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { + withBGApi { + Log.d(TAG, "ChatItemView downloadFileAction") + val user = chatModel.currentUser.value + if (user != null) { + controller.receiveFile(rhId, user, cItem.file.fileId) + } + } + showMenu.value = false + }) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + showMenu.value = false + }) + } + if (cItem.meta.itemDeleted == null && + (cItem.file == null || cItem.file.forwardingAllowed()) && + !cItem.isLiveDummy && !live + ) { + ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { + forwardItem(cInfo, cItem) + showMenu.value = false + }) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (revealed.value) { + HideItemAction(revealed, showMenu, reveal) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive) && !preview) { + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } + if (cItem.chatDir !is CIDirection.GroupSnd) { + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.groupFeatureEnabled(GroupFeature.Reports) && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + ReportItemAction(cItem, composeState, showMenu) + } + } + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.meta.itemDeleted != null -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + HideItemAction(revealed, showMenu, reveal) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu, reveal) + } else if (range.value != null) { + ExpandItemAction(revealed, showMenu, reveal) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.isDeletedContent -> { + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + ShrinkItemAction(revealed, showMenu, reveal) + } else { + ExpandItemAction(revealed, showMenu, reveal) + } + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + else -> { + DefaultDropdownMenu(showMenu) { + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (selectedChatItems.value == null) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + } + } + + @Composable + fun MarkedDeletedItemDropdownMenu() { + DefaultDropdownMenu(showMenu) { + if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu, reveal) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + + @Composable + fun ContentItem() { + val mc = cItem.content.msgContent + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + 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) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) + } else { + framedItemView() + } + } else { + framedItemView() + } + MsgContentItemDropdownMenu() + } + } + + @Composable fun LegacyDeletedItem() { + DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + + @Composable fun CallItem(status: CICallStatus, duration: Int) { + CICallItemView(cInfo, cItem, status, duration, showTimestamp = showTimestamp, acceptCall, cInfo.timedMessagesTTL) + DeleteItemMenu() + } + + fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems) + val members = when { + ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) + ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) + ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) + ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) + else -> "" + } + return if (count <= 1) { + null + } else if (ns.isEmpty()) { + generalGetString(MR.strings.rcv_group_events_count).format(count) + } else if (count > ns.size) { + members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) + } else { + members + } + } + + fun eventItemViewText(reversedChatItems: List): AnnotatedString { + val memberDisplayName = cItem.memberDisplayName + val t = mergedGroupEventText(cItem, reversedChatItems) + return if (!revealed.value && t != null) { + chatEventText(t, cItem.timestampText) + } else if (memberDisplayName != null) { + buildAnnotatedString { + withStyle(chatEventStyle) { append(memberDisplayName) } + append(" ") + }.plus(chatEventText(cItem)) + } else { + chatEventText(cItem) + } + } + + @Composable fun EventItemView() { + val reversedChatItems = chatsCtx.chatItems.value.asReversed() + CIEventView(eventItemViewText(reversedChatItems)) + } + + @Composable fun PendingReviewEventItemView() { + Text( + buildAnnotatedString { + withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } + + @Composable + fun DeletedItem() { + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { if (revealed.value) { HideItemAction(revealed, showMenu, reveal) @@ -420,275 +658,141 @@ 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 = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) } } } - cItem.isDeletedContent -> { - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { - DefaultDropdownMenu(showMenu) { - if (revealed.value) { - ShrinkItemAction(revealed, showMenu, reveal) - } else { - ExpandItemAction(revealed, showMenu, reveal) - } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - else -> { - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (selectedChatItems.value == null) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - } - } - @Composable - fun MarkedDeletedItemDropdownMenu() { - DefaultDropdownMenu(showMenu) { - if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu, reveal) + @Composable + fun E2EEInfoNoPQText() { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - @Composable - 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) - MarkedDeletedItemDropdownMenu() - } else { - if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) + @Composable + fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { + if (e2EEInfo.pqEnabled) { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) } else { - framedItemView() + E2EEInfoNoPQText() } - } else { - framedItemView() } - MsgContentItemDropdownMenu() - } - } - @Composable fun LegacyDeletedItem() { - DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> LegacyDeletedItem() + is CIContent.RcvDeleted -> LegacyDeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> if (developerTools) { + IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + DeleteItemMenu() + } else { + Box(Modifier.size(0.dp)) {} + } + is CIContent.RcvDecryptionError -> { + CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) + DeleteItemMenu() + } + is CIContent.RcvGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.SndGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + when (c.sndGroupEvent) { + is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + DeleteItemMenu() + } + is CIContent.SndChatPreference -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + 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(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(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndModerated -> DeletedItem() + is CIContent.RcvModerated -> DeletedItem() + is CIContent.RcvBlocked -> DeletedItem() + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.InvalidJSON -> { + CIInvalidJSONView(c.json) + DeleteItemMenu() + } } } } - - @Composable fun CallItem(status: CICallStatus, duration: Int) { - CICallItemView(cInfo, cItem, status, duration, showTimestamp = showTimestamp, acceptCall, cInfo.timedMessagesTTL) - DeleteItemMenu() - } - - fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): String? { - val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems) - val members = when { - ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) - ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) - ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) - ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) - else -> "" - } - return if (count <= 1) { - null - } else if (ns.isEmpty()) { - generalGetString(MR.strings.rcv_group_events_count).format(count) - } else if (count > ns.size) { - members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) - } else { - members - } - } - - fun eventItemViewText(reversedChatItems: List): AnnotatedString { - val memberDisplayName = cItem.memberDisplayName - val t = mergedGroupEventText(cItem, reversedChatItems) - return if (!revealed.value && t != null) { - chatEventText(t, cItem.timestampText) - } else if (memberDisplayName != null) { - buildAnnotatedString { - withStyle(chatEventStyle) { append(memberDisplayName) } - append(" ") - }.plus(chatEventText(cItem)) - } else { - chatEventText(cItem) - } - } - - @Composable fun EventItemView() { - val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() - CIEventView(eventItemViewText(reversedChatItems)) - } - - @Composable - fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun E2EEInfoNoPQText() { - Text( - buildAnnotatedString { - withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } - }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) - ) - } - - @Composable - fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { - if (e2EEInfo.pqEnabled) { - Text( - buildAnnotatedString { - withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } - }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) - ) - } else { - E2EEInfoNoPQText() - } - } - - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> LegacyDeletedItem() - is CIContent.RcvDeleted -> LegacyDeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> if (developerTools) { - IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) - DeleteItemMenu() - } else { - Box(Modifier.size(0.dp)) {} - } - is CIContent.RcvDecryptionError -> { - CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) - DeleteItemMenu() - } - is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.RcvDirectEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupEventContent -> { - when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - else -> EventItemView() - } - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.SndConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeature -> { - CIChatFeatureView(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) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - DeleteItemMenu() - } - is CIContent.SndChatPreference -> { - CIChatFeatureView(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) - 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) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(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) - MsgContentItemDropdownMenu() - } - is CIContent.SndModerated -> DeletedItem() - is CIContent.RcvModerated -> DeletedItem() - is CIContent.RcvBlocked -> DeletedItem() - is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.InvalidJSON -> { - CIInvalidJSONView(c.json) - DeleteItemMenu() - } + if (!cItem.chatDir.sent) { + GoToItemButton(false, bubbleHovered) } } - if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { ChatItemReactions() } @@ -740,6 +844,7 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( + chatsCtx: ChatModel.ChatsContext, cItem: ChatItem, revealed: State, showMenu: MutableState, @@ -748,14 +853,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) { @@ -807,14 +911,14 @@ fun ModerateItemAction( @Composable fun SelectItemAction( showMenu: MutableState, - selectChatItem: () -> Unit, + selectItem: () -> Unit, ) { ItemAction( stringResource(MR.strings.select_verb), painterResource(MR.images.ic_check_circle), onClick = { showMenu.value = false - selectChatItem() + selectItem() } ) } @@ -914,23 +1018,53 @@ private fun ReportItemAction( } @Composable -private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { +private fun ArchiveReportItemAction(id: Long, allowForAll: Boolean, showMenu: MutableState, archiveReports: (List, Boolean) -> Unit) { ItemAction( stringResource(MR.strings.archive_report), painterResource(MR.images.ic_inventory_2), onClick = { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.report_archive_alert_title), - text = generalGetString(MR.strings.report_archive_alert_desc), - onConfirm = { - deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) - }, - destructive = true, - confirmText = generalGetString(MR.strings.archive_verb), - ) + showArchiveReportsAlert(listOf(id), allowForAll, archiveReports) showMenu.value = false + } + ) +} + +fun showArchiveReportsAlert(ids: List, allowForAll: Boolean, archiveReports: (List, Boolean) -> Unit) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = if (ids.size == 1) { + generalGetString(MR.strings.report_archive_alert_title) + } else { + generalGetString(MR.strings.report_archive_alert_title_nth).format(ids.size) }, - color = Color.Red + text = null, + buttons = { + // Archive for me + SectionItemView({ + AlertManager.shared.hideAlert() + archiveReports(ids, false) + }) { + Text( + generalGetString(MR.strings.report_archive_for_me), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + if (allowForAll) { + // Archive for all moderators + SectionItemView({ + AlertManager.shared.hideAlert() + archiveReports(ids, true) + }) { + Text( + stringResource(MR.strings.report_archive_for_all_moderators), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + } + } ) } @@ -1078,7 +1212,7 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ -> val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) } - val width = if (sent && tailVisible) size.width - msgTailWidth else size.width + val width = size.width val height = size.height val rxMax = min(msgBubbleMaxRadius, width / 2) val ryMax = min(msgBubbleMaxRadius, height / 2) @@ -1193,6 +1327,12 @@ fun shapeStyle(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVisible: Bo } } +private fun closeReportsIfNeeded() { + if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.SECONDARY_CHAT)) { + ModalManager.end.closeModals() + } +} + fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { AlertManager.shared.showAlertDialog( title = generalGetString(cancelAction.alert.titleId), @@ -1297,6 +1437,7 @@ fun PreviewChatItemView( chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello") ) { ChatItemView( + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, ChatInfo.Direct.sampleData, chatItem, @@ -1305,16 +1446,20 @@ fun PreviewChatItemView( composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) }, + hoveredItemId = remember { mutableStateOf(null) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, + searchIsNotBlank = remember { mutableStateOf(false) }, selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, + scrollToItemId = remember { mutableStateOf(null) }, scrollToQuotedItemFromItem = {}, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, @@ -1343,6 +1488,7 @@ fun PreviewChatItemView( fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), @@ -1351,16 +1497,20 @@ fun PreviewChatItemViewDeletedContent() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) }, + hoveredItemId = remember { mutableStateOf(null) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, + searchIsNotBlank = remember { mutableStateOf(false) }, selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, + scrollToItemId = remember { mutableStateOf(null) }, scrollToQuotedItemFromItem = {}, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 784563dbb2..b2beba29e4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -27,6 +27,7 @@ import kotlin.math.ceil @Composable fun FramedItemView( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, ci: ChatItem, uriHandler: UriHandler? = null, @@ -39,6 +40,7 @@ fun FramedItemView( receiveFile: (Long) -> Unit, onLinkLongClick: (link: String) -> Unit = {}, scrollToItem: (Long) -> Unit = {}, + scrollToItemId: MutableState, scrollToQuotedItemFromItem: (Long) -> Unit = {}, ) { val sent = ci.chatDir.sent @@ -59,14 +61,18 @@ fun FramedItemView( style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface), linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, - showTimestamp = showTimestamp + showTimestamp = showTimestamp, ) } @Composable fun ciQuotedMsgView(qi: CIQuote) { Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + Modifier + // this width limitation prevents crash on calculating constraints that may happen if you post veeeery long message and then quote it. + // Top level layout wants `IntrinsicWidth.Max` and very long layout makes the crash in this case + .widthIn(max = 50000.dp) + .padding(vertical = 6.dp, horizontal = 12.dp), contentAlignment = Alignment.TopStart ) { val sender = qi.sender(membership()) @@ -76,7 +82,7 @@ fun FramedItemView( ) { Text( sender, - style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary), + style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), maxLines = 1 ) ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp) @@ -176,7 +182,7 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -202,7 +208,7 @@ fun FramedItemView( Column( Modifier .width(IntrinsicSize.Max) - .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) + .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { @Composable @@ -249,7 +255,11 @@ fun FramedItemView( onLongClick = { showMenu.value = true }, onClick = { if (ci.quotedItem.itemId != null) { - scrollToItem(ci.quotedItem.itemId) + if (ci.isReport && chatsCtx.secondaryContextFilter != null) { + scrollToItemId.value = ci.quotedItem.itemId + } else { + scrollToItem(ci.quotedItem.itemId) + } } else { scrollToQuotedItemFromItem(ci.id) } @@ -285,7 +295,7 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVideo -> { @@ -293,26 +303,26 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVoice -> { CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) if (mc.text != "") { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCFile -> ciFileView(ci, mc.text) is MsgContent.MCUnknown -> if (ci.file == null) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } else { ciFileView(ci, mc.text) } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCReport -> { @@ -321,9 +331,9 @@ fun FramedItemView( append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") } } - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) } - else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + else -> CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } @@ -344,6 +354,7 @@ fun FramedItemView( @Composable fun CIMarkdownText( ci: ChatItem, + chatInfo: ChatInfo, chatTTL: Int?, linkMode: SimplexLinkMode, uriHandler: UriHandler?, @@ -357,6 +368,10 @@ fun CIMarkdownText( MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, + mentions = ci.mentions, userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } 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 d63094cd1d..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) { @@ -95,7 +93,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, reve fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String = if (cItem.meta.itemDeleted != null && cItem.isReport) { - if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as? ChatInfo.Group)?.groupInfo?.membership?.groupMemberId) { generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName) } else { generalGetString(MR.strings.report_item_archived) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 257ede7d4a..ad11eb4897 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp @@ -22,7 +21,6 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* -import java.awt.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) @@ -60,6 +58,8 @@ fun MarkdownText ( sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, + mentions: Map? = null, + userMemberId: String? = null, toggleSecrets: Boolean, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, @@ -148,6 +148,26 @@ fun MarkdownText ( withAnnotation(tag = "SECRET", annotation = key) { if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } } + } else if (ft.format is Format.Mention) { + val mention = mentions?.get(ft.format.memberName) + + if (mention != null) { + if (mention.memberRef != null) { + val displayName = mention.memberRef.displayName + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { + displayName + } else { + "${mention.memberRef.localAlias} ($displayName)" + } + val mentionStyle = if (mention.memberId == userMemberId) ft.format.style.copy(color = MaterialTheme.colors.primary) else ft.format.style + + withStyle(mentionStyle) { append(mentionText(name)) } + } else { + withStyle( ft.format.style) { append(mentionText(ft.format.memberName)) } + } + } else { + append(ft.text) + } } else { val link = ft.link(linkMode) if (link != null) { @@ -291,3 +311,5 @@ private fun isRtl(s: CharSequence): Boolean { } return false } + +private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" 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 3ba15bc79c..52b4059eef 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 @@ -19,8 +19,7 @@ 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.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -104,13 +103,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ) } is ChatInfo.Local -> { + val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, {}) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, - click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, + click = defaultClickAction, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { NoteFolderMenuItems(chat, showMenu, showMarkRead) @@ -189,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) } } @@ -197,35 +197,56 @@ 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) -private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) = - apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +suspend fun openChat( + secondaryChatsCtx: ChatModel.ChatsContext?, + rhId: Long?, + chatType: ChatType, + apiId: Long, + openAroundItemId: Long? = null +) { + if (secondaryChatsCtx != null) { + chatModel.secondaryChatsContext.value = secondaryChatsCtx + } + apiLoadMessages( + chatsCtx = secondaryChatsCtx ?: chatModel.chatsContext, + rhId, + chatType, + apiId, + if (openAroundItemId != null) { + ChatPagination.Around(openAroundItemId, ChatPagination.INITIAL_COUNT) + } else { + ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + }, + "", + 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.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 = ChatPagination.Last(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 { @@ -244,6 +265,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo } chatModel.groupMembersIndexes.value = emptyMap() chatModel.groupMembers.value = newMembers + chatModel.membersLoaded.value = true chatModel.populateGroupMembersIndexes() } @@ -256,7 +278,7 @@ fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMen MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, contact.chatSettings.enableNtfs.nextMode(false), showMenu) TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) } @@ -282,7 +304,7 @@ fun GroupMenuItems( } } GroupMemberStatus.MemAccepted -> { - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -296,10 +318,15 @@ fun GroupMenuItems( MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, groupInfo.chatSettings.enableNtfs.nextMode(true), showMenu) TagListAction(chat, showMenu) + if (chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveAllReportsItemAction(showMenu) { + archiveAllReportsForMe(chat.remoteHostId, chat.chatInfo.apiId) + } + } ClearChatAction(chat, showMenu) - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -379,12 +406,12 @@ fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolea } @Composable -fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState) { +fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, nextMsgFilter: MsgFilter, showMenu: MutableState) { ItemAction( - if (ntfsEnabled) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + generalGetString(nextMsgFilter.text(chat.chatInfo.hasMentions)), + painterResource(nextMsgFilter.icon), onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled, chatModel) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextMsgFilter, chatModel) showMenu.value = false } ) @@ -521,7 +548,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 }, @@ -562,20 +589,33 @@ private fun InvalidDataView() { } } +@Composable +private fun ArchiveAllReportsItemAction(showMenu: MutableState, archiveReports: () -> Unit) { + ItemAction( + stringResource(MR.strings.archive_reports), + painterResource(MR.images.ic_inventory_2), + onClick = { + showArchiveAllReportsForMeAlert(archiveReports) + showMenu.value = false + } + ) +} + 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, chat.chatInfo.chatType, - chat.chatInfo.apiId + chat.chatInfo.apiId, + chat.chatInfo.groupChatScope() ) chat = chatModel.getChat(chat.id) ?: return@withApi } @@ -587,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) } } } @@ -609,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.updateChatTagReadInPrimaryContext(chat, wasUnread) } } } @@ -652,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) @@ -664,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) } } } @@ -682,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() } @@ -703,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 @@ -767,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), @@ -810,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 @@ -830,8 +870,8 @@ fun groupInvitationAcceptedAlert(rhId: Long?) { ) } -fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { - val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None) +fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, filter: MsgFilter, chatModel: ChatModel, currentState: MutableState? = null) { + val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = filter) updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel, currentState) } @@ -840,13 +880,13 @@ fun toggleChatFavorite(remoteHostId: Long?, chatInfo: ChatInfo, favorite: Boolea updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel) } -fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { +fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { val newChatInfo = when(chatInfo) { is ChatInfo.Direct -> with (chatInfo) { ChatInfo.Direct(contact.copy(chatSettings = chatSettings)) } is ChatInfo.Group -> with(chatInfo) { - ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings)) + ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings), groupChatScope = null) } else -> null } @@ -865,26 +905,45 @@ 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.All) { + 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.updateChatTagReadInPrimaryContext(updatedChat, wasUnread) } } val current = currentState?.value if (current != null) { - currentState.value = !current + currentState.value = chatSettings.enableNtfs } } } } +private fun showArchiveAllReportsForMeAlert(archiveReports: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_archive_alert_title_all), + text = generalGetString(MR.strings.report_archive_alert_desc_all), + onConfirm = archiveReports, + destructive = true, + confirmText = generalGetString(MR.strings.archive_verb), + ) +} + +private fun archiveAllReportsForMe(chatRh: Long?, apiId: Long) { + withBGApi { + val r = chatModel.controller.apiArchiveReceivedReports(chatRh, apiId) + if (r != null) { + controller.groupChatItemsDeleted(chatRh, r) + } + } +} + @Composable expect fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, 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 3205a084f0..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 @@ -127,32 +124,19 @@ fun ToggleChatListCard() { @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } - val rhId = chatModel.remoteHostId() LaunchedEffect(Unit) { val showWhatsNew = shouldShowWhatsNew(chatModel) val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false - if (showWhatsNew) { + if (showWhatsNew || showUpdatedConditions) { delay(1000L) ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) } - } else if (showUpdatedConditions) { - ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> - LaunchedEffect(Unit) { - val conditionsId = chatModel.conditions.value.currentConditions.conditionsId - try { - setConditionsNotified(rh = rhId, conditionsId = conditionsId) - } catch (e: Exception) { - Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}") - } - } - UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId) - } } } if (appPlatform.isDesktop) { KeyChangeEffect(chatModel.chatId.value) { - if (chatModel.chatId.value != null && !ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) { + if (chatModel.chatId.value != null && !ModalManager.end.isLastModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModalsExceptFirst() } AudioPlayer.stop() @@ -1219,7 +1203,7 @@ private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = when (activeFilter) { is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats) is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false - is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + is ActiveFilter.Unread -> chat.unreadTag else -> true } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index ba7334522a..65a9d175dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -19,7 +19,6 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontStyle @@ -71,6 +70,7 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemRejected -> inactiveIcon() GroupMemberStatus.MemLeft -> inactiveIcon() GroupMemberStatus.MemRemoved -> inactiveIcon() GroupMemberStatus.MemGroupDeleted -> inactiveIcon() @@ -157,7 +157,7 @@ fun ChatPreviewView( else if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary ) - GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary) + GroupMemberStatus.MemAccepted, GroupMemberStatus.MemRejected -> chatPreviewTitleText(MaterialTheme.colors.secondary) else -> chatPreviewTitleText( if (deleting) MaterialTheme.colors.secondary @@ -176,7 +176,7 @@ fun ChatPreviewView( if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { val sp20 = with(LocalDensity.current) { 20.sp.toDp() } val (text: CharSequence, inlineTextContent) = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } + chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null else -> markedDeletedText(ci, chat.chatInfo) to null } @@ -203,6 +203,11 @@ fun ChatPreviewView( cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName else -> null }, + mentions = ci.mentions, + userMemberId = when { + cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId + else -> null + }, toggleSecrets = false, linkMode = linkMode, senderBold = true, @@ -233,8 +238,11 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemRejected -> Text(stringResource(MR.strings.group_preview_rejected)) GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo)) GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary) + GroupMemberStatus.MemPendingReview, GroupMemberStatus.MemPendingApproval -> + Text(stringResource(MR.strings.reviewed_by_admins), color = MaterialTheme.colors.secondary) else -> {} } else -> {} @@ -357,7 +365,11 @@ fun ChatPreviewView( if (progressByTimeout) { progressView() } else if (chat.chatStats.reportsCount > 0) { - GroupReportsIcon() + FlagIcon(color = MaterialTheme.colors.error) + } else if (chat.supportUnreadCount > 0) { + FlagIcon(color = MaterialTheme.colors.primary) + } else if (chat.chatInfo.groupInfo_?.membership?.memberPending == true) { + FlagIcon(color = MaterialTheme.colors.secondary) } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -366,103 +378,132 @@ fun ChatPreviewView( } } - Row { - Box(contentAlignment = Alignment.BottomEnd) { - ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier) - Box(Modifier.padding(end = 6.sp.toDp(), bottom = 6.sp.toDp())) { - chatPreviewImageOverlayIcon() - } - } - Spacer(Modifier.width(8.dp)) - Column(Modifier.weight(1f)) { - Row { - Box(Modifier.weight(1f)) { - chatPreviewTitle() + Box(contentAlignment = Alignment.Center) { + Row { + Box(contentAlignment = Alignment.BottomEnd) { + ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier) + Box(Modifier.padding(end = 6.sp.toDp(), bottom = 6.sp.toDp())) { + chatPreviewImageOverlayIcon() } - Spacer(Modifier.width(8.sp.toDp())) - val ts = getTimestampText(chat.chatItems.lastOrNull()?.meta?.itemTs ?: chat.chatInfo.chatTs) - ChatListTimestampView(ts) } - Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) { - Row(Modifier.padding(top = 3.sp.toDp()).weight(1f)) { - val activeVoicePreview: MutableState<(ActiveVoicePreview)?> = remember(chat.id) { mutableStateOf(null) } - val chat = activeVoicePreview.value?.chat ?: chat - val ci = activeVoicePreview.value?.ci ?: chat.chatItems.lastOrNull() - val mc = ci?.content?.msgContent - val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null - val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null - if (ci != null && showContentPreview) { - chatItemContentPreview(chat, ci) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Row { + Box(Modifier.weight(1f)) { + chatPreviewTitle() } - if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { - Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) { - chatPreviewText() + Spacer(Modifier.width(8.sp.toDp())) + val ts = getTimestampText(chat.chatItems.lastOrNull()?.meta?.itemTs ?: chat.chatInfo.chatTs) + ChatListTimestampView(ts) + } + Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) { + Row(Modifier.padding(top = 3.sp.toDp()).weight(1f)) { + val activeVoicePreview: MutableState<(ActiveVoicePreview)?> = remember(chat.id) { mutableStateOf(null) } + val chat = activeVoicePreview.value?.chat ?: chat + val ci = activeVoicePreview.value?.ci ?: chat.chatItems.lastOrNull() + val mc = ci?.content?.msgContent + val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null + val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null + if (ci != null && showContentPreview) { + chatItemContentPreview(chat, ci) } - } - LaunchedEffect(AudioPlayer.currentlyPlaying.value, activeVoicePreview.value) { - val playing = AudioPlayer.currentlyPlaying.value - when { - playing == null -> activeVoicePreview.value = null - activeVoicePreview.value == null -> if (mc is MsgContent.MCVoice && playing.fileSource.filePath == ci.file?.fileSource?.filePath) { - activeVoicePreview.value = ActiveVoicePreview(chat, ci, mc) + if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { + Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) { + chatPreviewText() } - else -> if (playing.fileSource.filePath != ci?.file?.fileSource?.filePath) { - activeVoicePreview.value = null + } + LaunchedEffect(AudioPlayer.currentlyPlaying.value, activeVoicePreview.value) { + val playing = AudioPlayer.currentlyPlaying.value + when { + playing == null -> activeVoicePreview.value = null + activeVoicePreview.value == null -> if (mc is MsgContent.MCVoice && playing.fileSource.filePath == ci.file?.fileSource?.filePath) { + activeVoicePreview.value = ActiveVoicePreview(chat, ci, mc) + } + + else -> if (playing.fileSource.filePath != ci?.file?.fileSource?.filePath) { + activeVoicePreview.value = null + } + } + } + LaunchedEffect(chatModel.deletedChats.value) { + val voicePreview = activeVoicePreview.value + // Stop voice when deleting the chat + if (chatModel.deletedChats.value.contains(chatModel.remoteHostId() to chat.id) && voicePreview?.ci != null) { + AudioPlayer.stop(voicePreview.ci) } } } - LaunchedEffect(chatModel.deletedChats.value) { - val voicePreview = activeVoicePreview.value - // Stop voice when deleting the chat - if (chatModel.deletedChats.value.contains(chatModel.remoteHostId() to chat.id) && voicePreview?.ci != null) { - AudioPlayer.stop(voicePreview.ci) + + Spacer(Modifier.width(8.sp.toDp())) + + Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { + val n = chat.chatStats.unreadCount + val ntfsMode = chat.chatInfo.chatSettings?.enableNtfs + val showNtfsIcon = !chat.chatInfo.ntfsEnabled(false) && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) + if (n > 0 || chat.chatStats.unreadChat) { + val unreadMentions = chat.chatStats.unreadMentions + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { + val mentionColor = when { + disabled -> MaterialTheme.colors.secondary + cInfo is ChatInfo.Group -> { + val enableNtfs = cInfo.groupInfo.chatSettings.enableNtfs + if (enableNtfs == MsgFilter.All || enableNtfs == MsgFilter.Mentions) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary + } + + else -> if (showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant + } + if (unreadMentions > 0 && n > 1) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = mentionColor, + modifier = Modifier.size(12.sp.toDp()).offset(y = 3.sp.toDp()) + ) + } + + if (unreadMentions > 0 && n == 1) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(mentionColor, shape = CircleShape), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = Color.White, + modifier = Modifier.size(9.sp.toDp()) + ) + } + } else { + UnreadBadge( + text = if (n > 0) unreadCountStr(n) else "", + backgroundColor = if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, + yOffset = 3.dp + ) + } + } + } else if (showNtfsIcon && ntfsMode != null) { + Icon( + painterResource(ntfsMode.iconFilled), + contentDescription = generalGetString(MR.strings.notifications), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .padding(start = 2.sp.toDp()) + .size(18.sp.toDp()) + .offset(x = 2.5.sp.toDp(), y = 2.sp.toDp()) + ) + } else if (chat.chatInfo.chatSettings?.favorite == true) { + Icon( + painterResource(MR.images.ic_star_filled), + contentDescription = generalGetString(MR.strings.favorite_chat), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(20.sp.toDp()) + .offset(x = 2.5.sp.toDp()) + ) + } + Box( + Modifier.offset(y = 28.sp.toDp()), + contentAlignment = Alignment.Center + ) { + chatStatusImage() } - } - } - - Spacer(Modifier.width(8.sp.toDp())) - - Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { - val n = chat.chatStats.unreadCount - val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) - if (n > 0 || chat.chatStats.unreadChat) { - Text( - if (n > 0) unreadCountStr(n) else "", - color = Color.White, - fontSize = 10.sp, - style = TextStyle(textAlign = TextAlign.Center), - modifier = Modifier - .offset(y = 3.sp.toDp()) - .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) - .badgeLayout() - .padding(horizontal = 2.sp.toDp()) - .padding(vertical = 1.sp.toDp()) - ) - } else if (showNtfsIcon) { - Icon( - painterResource(MR.images.ic_notifications_off_filled), - contentDescription = generalGetString(MR.strings.notifications), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .padding(start = 2.sp.toDp()) - .size(18.sp.toDp()) - .offset(x = 2.5.sp.toDp(), y = 2.sp.toDp()) - ) - } else if (chat.chatInfo.chatSettings?.favorite == true) { - Icon( - painterResource(MR.images.ic_star_filled), - contentDescription = generalGetString(MR.strings.favorite_chat), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(20.sp.toDp()) - .offset(x = 2.5.sp.toDp()) - ) - } - Box( - Modifier.offset(y = 28.sp.toDp()), - contentAlignment = Alignment.Center - ) { - chatStatusImage() } } } @@ -506,11 +547,11 @@ fun IncognitoIcon(incognito: Boolean) { } @Composable -fun GroupReportsIcon() { +fun FlagIcon(color: Color) { Icon( painterResource(MR.images.ic_flag), contentDescription = null, - tint = MaterialTheme.colors.error, + tint = color, modifier = Modifier .size(21.sp.toDp()) .offset(x = 2.sp.toDp()) @@ -525,6 +566,26 @@ private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, g stringResource(MR.strings.group_preview_you_are_invited) } +@Composable +fun UnreadBadge( + text: String, + backgroundColor: Color, + yOffset: Dp? = null +) { + Text( + text, + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = yOffset ?: 0.dp) + .background(backgroundColor, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) +} + @Composable fun unreadCountStr(n: Int): String { return if (n < 1000) "$n" else "${n / 1000}" + stringResource(MR.strings.thousand_abbreviation) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 1b563e6d02..8dfe138da1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -31,8 +31,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.apiDeleteChatTag import chat.simplex.common.model.ChatController.apiSetChatTags 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.model.ChatModel.clearActiveChatFilterIfNeeded import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction @@ -42,6 +41,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* @Composable fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) { @@ -416,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) } } @@ -447,21 +447,19 @@ private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState) val tagId = tag.chatTagId if (apiDeleteChatTag(rhId, tagId)) { chatModel.userTags.value = chatModel.userTags.value.filter { it.chatTagId != tagId } - if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { - chatModel.activeChatTagFilter.value = null - } + clearActiveChatFilterIfNeeded() chatModel.chats.value.forEach { c -> 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/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 185ec3925f..13351a2111 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -634,7 +634,7 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) { } @Composable -fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean, hasPadding: Boolean) { +fun BoxScope.userUnreadBadge(unreadCount: Int, userMuted: Boolean, hasPadding: Boolean) { Text( if (unreadCount > 0) unreadCountStr(unreadCount) else "", color = Color.White, 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/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index c2e1d67d50..1c1c37b7ac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -435,7 +435,7 @@ suspend fun encryptDatabase( } val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) appPrefs.encryptionStartedAt.set(null) - val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError + val sqliteError = ((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError when { sqliteError is SQLiteError.ErrorNotADatabase -> { operationEnded(m, progressIndicator) { @@ -449,7 +449,7 @@ suspend fun encryptDatabase( error != null -> { operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), - "failed to set storage encryption: ${error.responseType} ${error.details}" + "failed to set storage encryption: error ${error.string}" ) } false 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 d951f1f812..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 @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* @@ -20,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.* @@ -35,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() { @@ -108,6 +108,9 @@ fun DatabaseView() { } }, onChatItemTTLSelected = { + if (it == null) { + return@DatabaseLayout + } val oldValue = chatItemTTL.value chatItemTTL.value = it if (it < oldValue) { @@ -158,7 +161,7 @@ fun DatabaseLayout( exportArchive: () -> Unit, deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, - onChatItemTTLSelected: (ChatItemTTL) -> Unit, + onChatItemTTLSelected: (ChatItemTTL?) -> Unit, disconnectAllHosts: () -> Unit, ) { val operationsDisabled = progressIndicator && !chatModel.desktopNoUserNoRemote @@ -300,21 +303,25 @@ private fun setChatItemTTLAlert( } @Composable -private fun TtlOptions(current: State, enabled: State, onSelected: (ChatItemTTL) -> Unit) { +fun TtlOptions( + current: State, + enabled: State, + onSelected: (ChatItemTTL?) -> Unit, + default: State? = null +) { val values = remember { - val all: ArrayList = arrayListOf(ChatItemTTL.None, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day) - if (current.value is ChatItemTTL.Seconds) { - all.add(current.value) + val all: ArrayList = arrayListOf(ChatItemTTL.None, ChatItemTTL.Year, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day) + val currentValue = current.value + if (currentValue is ChatItemTTL.Seconds) { + all.add(currentValue) } - all.map { - when (it) { - is ChatItemTTL.None -> it to generalGetString(MR.strings.chat_item_ttl_none) - is ChatItemTTL.Day -> it to generalGetString(MR.strings.chat_item_ttl_day) - is ChatItemTTL.Week -> it to generalGetString(MR.strings.chat_item_ttl_week) - is ChatItemTTL.Month -> it to generalGetString(MR.strings.chat_item_ttl_month) - is ChatItemTTL.Seconds -> it to String.format(generalGetString(MR.strings.chat_item_ttl_seconds), it.secs) - } + val options: MutableList> = all.map { it to it.text }.toMutableList() + + if (default != null) { + options.add(null to String.format(generalGetString(MR.strings.chat_item_ttl_default), default.value.text)) } + + options } ExposedDropDownSettingRow( generalGetString(MR.strings.delete_messages_after), @@ -530,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() @@ -777,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/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt index 4fdbd97d23..078cdde9da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt @@ -9,3 +9,5 @@ fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing) fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) + +fun mentionPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index cce7cf17a5..9c529e547a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layoutId import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -19,6 +20,8 @@ import chat.simplex.common.model.LinkPreview import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.chatViewScrollState +import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID +import chat.simplex.common.views.chat.item.imageViewFullWidth import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -40,11 +43,21 @@ suspend fun getLinkPreview(url: String): LinkPreview? { url } else -> { - val response = Jsoup.connect(url) + val connection = Jsoup.connect(url) .ignoreContentType(true) .timeout(10000) .followRedirects(true) - .execute() + + val response = if (url.lowercase().startsWith("https://x.com/")) { + // Apple sends request with special user-agent which handled differently by X.com. + // Different response that includes video poster from post + connection + .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0") + .execute() + } else { + connection + .execute() + } val doc = response.parse() val ogTags = doc.select(OG_SELECT_QUERY) title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title() @@ -123,10 +136,15 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel @Composable fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongClick: () -> Unit) { - Column(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { + val image = base64ToBitmap(linkPreview.image) + Column( + Modifier + .layoutId(CHAT_IMAGE_LAYOUT_ID) + .width(if (image.width * 0.97 <= image.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) + ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } Image( - base64ToBitmap(linkPreview.image), + image, stringResource(MR.strings.image_descr_link_preview), modifier = Modifier .fillMaxWidth() 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 3e24629ab1..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 @@ -32,6 +32,7 @@ fun ModalView( searchAlwaysVisible: Boolean = false, onSearchValueChanged: (String) -> Unit = {}, endButtons: @Composable RowScope.() -> Unit = {}, + appBar: @Composable (BoxScope.() -> Unit)? = null, content: @Composable BoxScope.() -> Unit, ) { if (showClose && showAppBar) { @@ -48,14 +49,20 @@ fun ModalView( StatusBarBackground() } Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) { - DefaultAppBar( - navigationButton = if (showClose) {{ NavigationButtonBack(onButtonClicked = if (enableClose) close else null) }} else null, - onTop = !oneHandUI.value, - showSearch = showSearch, - searchAlwaysVisible = searchAlwaysVisible, - onSearchValueChanged = onSearchValueChanged, - buttons = endButtons - ) + if (appBar != null) { + appBar() + } else { + DefaultAppBar( + navigationButton = if (showClose) { + { NavigationButtonBack(onButtonClicked = if (enableClose) close else null) } + } else null, + onTop = !oneHandUI.value, + showSearch = showSearch, + searchAlwaysVisible = searchAlwaysVisible, + onSearchValueChanged = onSearchValueChanged, + buttons = endButtons + ) + } } } } @@ -78,7 +85,7 @@ class ModalData(val keyboardCoversBar: Boolean = true) { } enum class ModalViewId { - GROUP_REPORTS + SECONDARY_CHAT } class ModalManager(private val placement: ModalPlacement? = null) { @@ -154,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/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 8588e0e981..03542ca8af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -468,12 +468,12 @@ private suspend fun MutableState.verifyDatabasePassphrase(db val error = controller.testStorageEncryption(dbKey) if (error == null) { state = MigrationFromState.UploadConfirmation - } else if (((error.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorOpen)?.sqliteError is SQLiteError.ErrorNotADatabase) { + } else if (((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorOpen)?.sqliteError is SQLiteError.ErrorNotADatabase) { showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase("")) } else { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.error), - text = generalGetString(MR.strings.migrate_from_device_error_verifying_passphrase) + " " + error.details + text = generalGetString(MR.strings.migrate_from_device_error_verifying_passphrase) + " " + error.string ) } } @@ -556,11 +556,12 @@ private fun MutableState.startUploading( ) { withBGApi { chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> - when (msg) { + val r = msg.result + when (r) { is CR.SndFileProgressXFTP -> { val s = state if (s is MigrationFromState.UploadProgress && s.uploadedBytes != s.totalBytes) { - state = MigrationFromState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) + state = MigrationFromState.UploadProgress(r.sentSize, r.totalSize, r.fileTransferMeta.fileId, archivePath, ctrl, user) } } is CR.SndFileRedirectStartXFTP -> { @@ -578,7 +579,7 @@ private fun MutableState.startUploading( requiredHostMode = cfg.requiredHostMode ) ) - state = MigrationFromState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + state = MigrationFromState.LinkShown(r.fileTransferMeta.fileId, data.addToLink(r.rcvURIs[0]), ctrl) } is CR.SndFileError -> { AlertManager.shared.showAlertMsg( @@ -692,7 +693,7 @@ private class MigrationFromChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, - val processReceivedMsg: suspend (CR) -> Unit + val processReceivedMsg: suspend (API) -> Unit ) { fun start() { Log.d(TAG, "MigrationChatReceiver startReceiver") @@ -701,19 +702,18 @@ private class MigrationFromChatReceiver( try { val msg = ChatController.recvMsg(ctrl) if (msg != null && receiveMessages) { - val r = msg.resp - val rhId = msg.remoteHostId - Log.d(TAG, "processReceivedMsg: ${r.responseType}") - chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val rhId = msg.rhId + Log.d(TAG, "processReceivedMsg: ${msg.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, msg)) val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { - processReceivedMsg(r) + processReceivedMsg(msg) } if (finishedWithoutTimeout == null) { - Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + Log.e(TAG, "Timeout reached while processing received message: " + msg.responseType) if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.possible_slow_function_title), - text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.responseType + "\n" + Exception().stackTraceToString()), shareText = true ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 1a28bbf589..d74846f8a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -580,12 +580,13 @@ private fun MutableState.startDownloading( ) { withBGApi { chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> - when (msg) { - is CR.RcvFileProgressXFTP -> { - state = MigrationToState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, networkProxy, ctrl) + val r = msg.result + when { + r is CR.RcvFileProgressXFTP -> { + state = MigrationToState.DownloadProgress(r.receivedSize, r.totalSize, r.rcvFileTransfer.fileId, link, archivePath, netCfg, networkProxy, ctrl) MigrationToDeviceState.save(MigrationToDeviceState.DownloadProgress(link, File(archivePath).name, netCfg, networkProxy)) } - is CR.RcvStandaloneFileComplete -> { + r is CR.RcvStandaloneFileComplete -> { delay(500) // User closed the whole screen before new state was saved if (state == null) { @@ -595,22 +596,22 @@ private fun MutableState.startDownloading( MigrationToDeviceState.save(MigrationToDeviceState.ArchiveImport(File(archivePath).name, netCfg, networkProxy)) } } - is CR.RcvFileError -> { + r is CR.RcvFileError -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.migrate_to_device_download_failed), generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg, networkProxy) } - is CR.ChatRespError -> { - if (msg.chatError is ChatError.ChatErrorChat && msg.chatError.errorType is ChatErrorType.NoRcvFileUser) { + msg is API.Error -> { + if (msg.err is ChatError.ChatErrorChat && msg.err.errorType is ChatErrorType.NoRcvFileUser) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.migrate_to_device_download_failed), generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg, networkProxy) } else { - Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.chatError)}") + Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.err)}") } } else -> Log.d(TAG, "unsupported event: ${msg.responseType}") @@ -739,7 +740,7 @@ private class MigrationToChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, - val processReceivedMsg: suspend (CR) -> Unit + val processReceivedMsg: suspend (API) -> Unit ) { fun start() { Log.d(TAG, "MigrationChatReceiver startReceiver") @@ -748,19 +749,18 @@ private class MigrationToChatReceiver( try { val msg = ChatController.recvMsg(ctrl) if (msg != null && receiveMessages) { - val r = msg.resp - val rhId = msg.remoteHostId - Log.d(TAG, "processReceivedMsg: ${r.responseType}") - chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val rhId = msg.rhId + Log.d(TAG, "processReceivedMsg: ${msg.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, msg)) val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { - processReceivedMsg(r) + processReceivedMsg(msg) } if (finishedWithoutTimeout == null) { - Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + Log.e(TAG, "Timeout reached while processing received message: " + msg.responseType) if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.possible_slow_function_title), - text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.responseType + "\n" + Exception().stackTraceToString()), shareText = true ) } 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..2db40f770d 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,9 @@ 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.chatId.value = groupInfo.id } setGroupMembers(rhId, groupInfo, chatModel) @@ -57,7 +56,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..74c9a55ecf 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,34 @@ 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), + small = short && connLink.connShortLink != null, + modifier, + padding, + tintColor, + withLogo, + onShare, + ) +} + @Composable fun SimpleXLinkQRCode( connReq: String, @@ -30,6 +51,7 @@ fun SimpleXLinkQRCode( ) { QRCode( simplexChatLink(connReq), + small = connReq.count() < 200, modifier, padding, tintColor, @@ -38,17 +60,10 @@ 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, + small: Boolean = false, modifier: Modifier = Modifier, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING * 2f, vertical = DEFAULT_PADDING_HALF), tintColor: Color = Color(0xff062d56), @@ -56,9 +71,11 @@ fun QRCode( onShare: (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() + val logoSize = if (small) 0.21f else 0.16f + val errorLevel = if (small) QrCode.ErrorLevel.M else QrCode.ErrorLevel.L val qr = remember(connReq, tintColor, withLogo) { - qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) - .let { if (withLogo) it.addLogo() else it } + qrCodeBitmap(connReq, 1024, errorLevel).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) + .let { if (withLogo) it.addLogo(logoSize) else it } } Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Image( @@ -67,12 +84,13 @@ fun QRCode( Modifier .padding(padding) .widthIn(max = 400.dp) + .fillMaxWidth(if (small) 0.63f else 1f) .aspectRatio(1f) .then(modifier) .clickable { scope.launch { - val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) - .let { if (withLogo) it.addLogo() else it } + val image = qrCodeBitmap(connReq, 1024, errorLevel).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) + .let { if (withLogo) it.addLogo(logoSize) else it } val file = saveTempImageUncompressed(image, true) if (file != null) { shareFile("", CryptoFile.plain(file.absolutePath)) @@ -84,8 +102,8 @@ fun QRCode( } } -fun qrCodeBitmap(content: String, size: Int = 1024): ImageBitmap { - val qrCode = QrCodeEncoder().addAutomatic(content).setError(QrCode.ErrorLevel.L).fixate() +fun qrCodeBitmap(content: String, size: Int = 1024, errorLevel: QrCode.ErrorLevel): ImageBitmap { + val qrCode = QrCodeEncoder().addAutomatic(content).setError(errorLevel).fixate() /** See [QrCodeGeneratorImage.initialize] and [FiducialImageEngine.configure] for size calculation */ val numModules = QrCode.totalModules(qrCode.version) // Hide border on light themes to make it fit to the same place as camera in QRCodeScanner. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index 2f84166362..a14f163a91 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -7,15 +7,18 @@ import SectionTextFooter import SectionView import TextIconSpaced import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight 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.ChatController.appPrefs import chat.simplex.common.platform.* @@ -27,11 +30,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun ModalData.ChooseServerOperators( - onboarding: Boolean, - close: (() -> Unit) = { ModalManager.fullscreen.closeModals() }, - modalManager: ModalManager = ModalManager.fullscreen -) { +fun ModalData.OnboardingConditionsView() { LaunchedEffect(Unit) { prepareChatBeforeFinishingOnboarding() } @@ -41,6 +40,73 @@ fun ModalData.ChooseServerOperators( val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } } val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } } + ColumnWithScrollBar( + Modifier + .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), + maxIntrinsicSize = true + ) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), bottomPadding = DEFAULT_PADDING) + } + + Spacer(Modifier.weight(1f)) + Column( + (if (appPlatform.isDesktop) Modifier.width(450.dp).align(Alignment.CenterHorizontally) else Modifier) + .fillMaxWidth() + .padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING), + horizontalAlignment = Alignment.Start + ) { + Text( + stringResource(MR.strings.onboarding_conditions_private_chats_not_accessible), + style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp) + ) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.onboarding_conditions_by_using_you_agree), + style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp) + ) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.onboarding_conditions_privacy_policy_and_conditions_of_use), + style = TextStyle(fontSize = 17.sp), + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + ModalManager.fullscreen.showModal(endButtons = { ConditionsLinkButton() }) { + SimpleConditionsView(rhId = null) + } + } + ) + } + Spacer(Modifier.weight(1f)) + + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperators, selectedOperatorIds) + TextButtonBelowOnboardingButton(stringResource(MR.strings.onboarding_conditions_configure_server_operators)) { + ModalManager.fullscreen.showModalCloseable { close -> + ChooseServerOperators(serverOperators, selectedOperatorIds, close) + } + } + } + } + } + } +} + +@Composable +fun ModalData.ChooseServerOperators( + serverOperators: State>, + selectedOperatorIds: MutableState>, + close: (() -> Unit) +) { + LaunchedEffect(Unit) { + prepareChatBeforeFinishingOnboarding() + } + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false) { ColumnWithScrollBar( Modifier .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), @@ -53,7 +119,7 @@ fun ModalData.ChooseServerOperators( Column(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingInformationButton( stringResource(MR.strings.how_it_helps_privacy), - onClick = { modalManager.showModal { ChooseServerOperatorsInfoView(modalManager) } } + onClick = { ModalManager.fullscreen.showModal { ChooseServerOperatorsInfoView() } } ) } @@ -77,42 +143,11 @@ fun ModalData.ChooseServerOperators( } Spacer(Modifier.weight(1f)) - val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } - val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed } - val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() - Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { val enabled = selectedOperatorIds.value.isNotEmpty() - when { - reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager) - selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close) - else -> ContinueButton(enabled, onboarding, close) - } - if (onboarding && reviewForOperators.isEmpty()) { - TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) { - modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> - UsageConditionsView( - currUserServers = remember { mutableStateOf(emptyList()) }, - userServers = remember { mutableStateOf(emptyList()) }, - close = close, - rhId = null, - ) - } - } - } else if (onboarding || reviewForOperators.isEmpty()) { - // Reserve space - TextButtonBelowOnboardingButton("", null) - } - if (!onboarding && reviewForOperators.isNotEmpty()) { - ReviewLaterButton(canReviewLater, close) - SectionTextFooter( - annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) + - AnnotatedString(" ") + - annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure), - textAlign = TextAlign.Center - ) - SectionBottomSpacer() - } + SetOperatorsButton(enabled, close) + // Reserve space + TextButtonBelowOnboardingButton("", null) } } } @@ -167,115 +202,36 @@ private fun CircleCheckbox(checked: Boolean) { } @Composable -private fun ReviewConditionsButton( - enabled: Boolean, - onboarding: Boolean, - selectedOperators: State>, - selectedOperatorIds: State>, - modalManager: ModalManager -) { +private fun SetOperatorsButton(enabled: Boolean, close: () -> Unit) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.operator_review_conditions, + labelId = MR.strings.ok, onboarding = null, enabled = enabled, onclick = { - modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> - ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close) - } + close() } ) } -@Composable -private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State>, selectedOperatorIds: State>, close: () -> Unit) { - OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.onboarding_network_operators_update, - onboarding = null, - enabled = enabled, - onclick = { - withBGApi { - val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value) - if (enabledOperators != null) { - val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators) - if (r != null) { - chatModel.conditions.value = r - } - continueToNextStep(onboarding, close) - } - } - } - ) -} - -@Composable -private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) { - OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.onboarding_network_operators_continue, - onboarding = null, - enabled = enabled, - onclick = { - continueToNextStep(onboarding, close) - } - ) -} - -@Composable -private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) { - TextButtonBelowOnboardingButton( - stringResource(MR.strings.onboarding_network_operators_review_later), - onClick = if (!enabled) null else {{ continueToNextStep(false, close) }} - ) -} - -@Composable -private fun ReviewConditionsView( - onboarding: Boolean, - selectedOperators: State>, - selectedOperatorIds: State>, - close: () -> Unit -) { - // remembering both since we don't want to reload the view after the user accepts conditions - val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } } - val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } } - ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = if (onboarding) DEFAULT_ONBOARDING_HORIZONTAL_PADDING else DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false, bottomPadding = DEFAULT_PADDING) - if (operatorsWithConditionsAccepted.isNotEmpty()) { - ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ }) - ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ }) - } else { - ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ }) - } - Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) { - ConditionsTextView(chatModel.remoteHostId()) - } - Column(Modifier.padding(vertical = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { - AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close) - } - } -} - @Composable private fun AcceptConditionsButton( - onboarding: Boolean, + enabled: Boolean, selectedOperators: State>, - selectedOperatorIds: State>, - close: () -> Unit + selectedOperatorIds: State> ) { fun continueOnAccept() { - if (appPlatform.isDesktop || !onboarding) { - if (onboarding) { close() } - continueToNextStep(onboarding, close) + if (appPlatform.isDesktop) { + continueToNextStep() } else { continueToSetNotificationsAfterAccept() } } OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.fillMaxWidth() else Modifier, - labelId = MR.strings.accept_conditions, + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.onboarding_conditions_accept, onboarding = null, + enabled = enabled, onclick = { withBGApi { val conditionsId = chatModel.conditions.value.currentConditions.conditionsId @@ -300,12 +256,8 @@ private fun AcceptConditionsButton( ) } -private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) { - if (onboarding) { +private fun continueToNextStep() { appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete) - } else { - close() - } } private fun continueToSetNotificationsAfterAccept() { @@ -344,9 +296,7 @@ private fun enabledOperators(operators: List, selectedOperatorId } @Composable -private fun ChooseServerOperatorsInfoView( - modalManager: ModalManager -) { +private fun ChooseServerOperatorsInfoView() { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.onboarding_network_operators)) @@ -362,21 +312,20 @@ private fun ChooseServerOperatorsInfoView( SectionView(title = stringResource(MR.strings.onboarding_network_about_operators).uppercase()) { chatModel.conditions.value.serverOperators.forEach { op -> - ServerOperatorRow(op, modalManager) + ServerOperatorRow(op) } } SectionBottomSpacer() } } -@Composable() +@Composable private fun ServerOperatorRow( - operator: ServerOperator, - modalManager: ModalManager + operator: ServerOperator ) { SectionItemView( { - modalManager.showModalCloseable { close -> + ModalManager.fullscreen.showModalCloseable { close -> OperatorInfoView(operator) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index a66ef9ff7a..52eea3dd9d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -14,6 +14,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatController.appPrefs @@ -163,11 +164,20 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool Text( stringResource(MR.strings.view_updated_conditions), color = MaterialTheme.colors.primary, - modifier = Modifier.clickable { - modalManager.showModalCloseable { - close -> UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId) + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + modalManager.showModalCloseable { close -> + UsageConditionsView( + userServers = mutableStateOf(emptyList()), + currUserServers = mutableStateOf(emptyList()), + close = close, + rhId = rhId + ) + } } - } ) } @@ -176,14 +186,21 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool Box( Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - Text( - generalGetString(MR.strings.ok), - modifier = Modifier.clickable(onClick = { - close() - }), - style = MaterialTheme.typography.h3, - color = MaterialTheme.colors.primary - ) + Box(Modifier.clip(RoundedCornerShape(20.dp))) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clickable { close() } + .padding(8.dp) + ) { + Text( + generalGetString(MR.strings.ok), + style = MaterialTheme.typography.h3, + color = MaterialTheme.colors.primary + ) + } + } } Spacer(Modifier.fillMaxHeight().weight(1f)) } @@ -199,8 +216,17 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool fun ReadMoreButton(url: String) { val uriHandler = LocalUriHandler.current Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = DEFAULT_PADDING.div(4))) { - Text(stringResource(MR.strings.whats_new_read_more), color = MaterialTheme.colors.primary, - modifier = Modifier.clickable { uriHandler.openUriCatching(url) }) + Text( + stringResource(MR.strings.whats_new_read_more), + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + uriHandler.openUriCatching(url) + } + ) Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.whats_new_read_more), tint = MaterialTheme.colors.primary) } } @@ -737,17 +763,7 @@ private val versionDescriptions: List = listOf( val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo Image(painterResource(src), null, modifier = Modifier.height(48.dp)) Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp)) - Row { - Text( - stringResource(MR.strings.v6_2_network_decentralization_enable_flux), - color = MaterialTheme.colors.primary, - modifier = Modifier.clickable { - modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) } - } - ) - Text(" ") - Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason)) - } + Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux)) } } ), @@ -762,6 +778,45 @@ private val versionDescriptions: List = listOf( descrId = MR.strings.v6_2_improved_chat_navigation_descr ), ), + ), + VersionDescription( + version = "v6.3", + post = "https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html", + features = listOf( + VersionFeature.FeatureDescription( + icon = MR.images.ic_at, + titleId = MR.strings.v6_3_mentions, + descrId = MR.strings.v6_3_mentions_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_flag, + titleId = MR.strings.v6_3_reports, + descrId = MR.strings.v6_3_reports_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_menu, + titleId = MR.strings.v6_3_organize_chat_lists, + descrId = MR.strings.v6_3_organize_chat_lists_descr + ), + VersionFeature.FeatureDescription( + icon = null, + titleId = MR.strings.v6_3_better_privacy_and_security, + descrId = null, + subfeatures = listOf( + MR.images.ic_visibility_off to MR.strings.v6_3_private_media_file_names, + MR.images.ic_delete to MR.strings.v6_3_set_message_expiration_in_chats + ) + ), + VersionFeature.FeatureDescription( + icon = null, + titleId = MR.strings.v6_3_better_groups_performance, + descrId = null, + subfeatures = listOf( + MR.images.ic_bolt to MR.strings.v6_3_faster_sending_messages, + MR.images.ic_group_off to MR.strings.v6_3_faster_deletion_of_groups + ) + ), + ) ) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 3b6e176ca3..8bb84060c2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -492,7 +492,7 @@ private suspend fun connectDesktopAddress(sessionAddress: MutableState, } } -private suspend fun connectDesktop(sessionAddress: MutableState, connect: suspend () -> Pair): Boolean { +private suspend fun connectDesktop(sessionAddress: MutableState, connect: suspend () -> Pair): Boolean { val res = connect() if (res.first != null) { val (rc_, ctrlAppInfo, v) = res.first!! @@ -505,13 +505,13 @@ private suspend fun connectDesktop(sessionAddress: MutableState, connect } else { val e = res.second ?: return false when { - e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() - e.chatError is ChatError.ChatErrorChat && e.chatError.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() - e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.chatError.remoteCtrlError.appVersion) - e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) - e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() + e is ChatError.ChatErrorRemoteCtrl && e.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() + e is ChatError.ChatErrorRemoteCtrl && e.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.remoteCtrlError.appVersion) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.RCP && e.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.RCP && e.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() else -> { - val errMsg = "${e.responseType}: ${e.details}" + val errMsg = "error: ${e.string}" Log.e(TAG, "bad response: $errMsg") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), errMsg) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index b4fead6692..7a1fc21b17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -1096,6 +1096,7 @@ object AppearanceScope { "en" to "English", "ar" to "العربية", "bg" to "Български", + "ca" to "Català", "cs" to "Čeština", "de" to "Deutsch", "es" to "Español", 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..569f4ff5f8 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) } } } @@ -284,7 +291,7 @@ private fun DeliveryReceiptsSection( SectionView(stringResource(MR.strings.settings_section_title_delivery_receipts)) { SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.receipts_section_contacts)) { DefaultSwitch( - checked = currentUser.sendRcptsContacts ?: false, + checked = currentUser.sendRcptsContacts, onCheckedChange = { enable -> setOrAskSendReceiptsContacts(enable) } @@ -292,7 +299,7 @@ private fun DeliveryReceiptsSection( } SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.receipts_section_groups)) { DefaultSwitch( - checked = currentUser.sendRcptsSmallGroups ?: false, + checked = currentUser.sendRcptsSmallGroups, onCheckedChange = { enable -> setOrAskSendReceiptsGroups(enable) } 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..d77c4bc7f6 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 @@ -42,6 +43,7 @@ fun UserAddressView( KeyChangeEffect(user.value?.remoteHostId, user.value?.userId) { close() } + fun setProfileAddress(on: Boolean) { progressIndicator = true withBGApi { @@ -61,7 +63,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) @@ -79,6 +82,17 @@ fun UserAddressView( } } + fun addShortLink() { + withBGApi { + progressIndicator = true + val userAddress = chatModel.controller.apiAddMyAddressShortLink(user.value?.remoteHostId) + if (userAddress != null) { + chatModel.userAddress.value = userAddress + } + progressIndicator = false + } + } + LaunchedEffect(autoCreateAddress) { if (chatModel.userAddress.value == null && autoCreateAddress) { createAddress() @@ -93,6 +107,7 @@ fun UserAddressView( userAddress = userAddress.value, shareViaProfile, createAddress = { createAddress() }, + addShortLink = { addShortLink() }, learnMore = { ModalManager.start.showModal { UserAddressLearnMore() @@ -102,7 +117,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, @@ -167,6 +182,7 @@ private fun UserAddressLayout( userAddress: UserContactLinkRec?, shareViaProfile: MutableState, createAddress: () -> Unit, + addShortLink: () -> Unit, learnMore: () -> Unit, share: (String) -> Unit, sendEmail: (UserContactLinkRec) -> Unit, @@ -198,13 +214,20 @@ 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) + if (userAddress.connLinkContact.connShortLink == null && appPreferences.privacyShortLinks.get()) { + AddShortLinkButton(addShortLink) + } if (autoAcceptState.value.business) { SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) @@ -242,6 +265,17 @@ private fun CreateAddressButton(onClick: () -> Unit) { ) } +@Composable +private fun AddShortLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.add_short_link), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable private fun CreateOneTimeLinkButton() { val closeAll = { ModalManager.start.closeModals() } @@ -553,6 +587,7 @@ fun PreviewUserAddressLayoutNoAddress() { user = User.sampleData, userAddress = null, createAddress = {}, + addShortLink = {}, share = { _ -> }, deleteAddress = {}, saveAas = { _, _ -> }, @@ -584,8 +619,9 @@ 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 = {}, + addShortLink = {}, share = { _ -> }, deleteAddress = {}, saveAas = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index fc042cc46c..0c38b0c045 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -19,6 +19,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* @@ -26,6 +27,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.res.MR import java.text.DecimalFormat @@ -44,6 +46,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U val sessionMode = remember { mutableStateOf(currentCfgVal.sessionMode) } val smpProxyMode = remember { mutableStateOf(currentCfgVal.smpProxyMode) } val smpProxyFallback = remember { mutableStateOf(currentCfgVal.smpProxyFallback) } + val smpWebPortServers = remember { mutableStateOf(currentCfgVal.smpWebPortServers) } val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) } val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) } @@ -82,6 +85,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode.value, smpProxyMode = smpProxyMode.value, smpProxyFallback = smpProxyFallback.value, + smpWebPortServers = smpWebPortServers.value, tcpConnectTimeout = networkTCPConnectTimeout.value, tcpTimeout = networkTCPTimeout.value, tcpTimeoutPerKb = networkTCPTimeoutPerKb.value, @@ -96,6 +100,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode.value = cfg.sessionMode smpProxyMode.value = cfg.smpProxyMode smpProxyFallback.value = cfg.smpProxyFallback + smpWebPortServers.value = cfg.smpWebPortServers networkTCPConnectTimeout.value = cfg.tcpConnectTimeout networkTCPTimeout.value = cfg.tcpTimeout networkTCPTimeoutPerKb.value = cfg.tcpTimeoutPerKb @@ -150,6 +155,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, + smpWebPortServers, networkTCPConnectTimeout, networkTCPTimeout, networkTCPTimeoutPerKb, @@ -182,6 +188,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode: MutableState, smpProxyMode: MutableState, smpProxyFallback: MutableState, + smpWebPortServers: MutableState, networkTCPConnectTimeout: MutableState, networkTCPTimeout: MutableState, networkTCPTimeoutPerKb: MutableState, @@ -214,75 +221,85 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U } SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) SectionDividerSpaced(maxTopPadding = true) - } - if (currentRemoteHost == null) { SectionView(stringResource(MR.strings.network_session_mode_transport_isolation).uppercase()) { SessionModePicker(sessionMode, showModal, updateSessionMode) } SectionDividerSpaced() - } + SectionView(stringResource(MR.strings.network_smp_web_port_section_title).uppercase()) { + ExposedDropDownSettingRow( + stringResource(MR.strings.network_smp_web_port_toggle), + SMPWebPortServers.entries.map { it to stringResource(it.text) }, + smpWebPortServers + ) { smpWebPortServers.value = it } + } + SectionTextFooter( + if (smpWebPortServers.value == SMPWebPortServers.Preset) stringResource(MR.strings.network_smp_web_port_preset_footer) + else String.format(stringResource(MR.strings.network_smp_web_port_footer), if (smpWebPortServers.value == SMPWebPortServers.All) "443" else "5223") + ) + SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, - listOf(10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000), secondsLabel - ) - } - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, - listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel - ) - } - SectionItemView { - // can't be higher than 130ms to avoid overflow on 32bit systems - TimeoutSettingRow( - stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel - ) - } - // SectionItemView { - // IntSettingRow( - // stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency, - // listOf(1, 2, 4, 8, 12, 16, 24), "" - // ) - // } - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval, - listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel - ) - } - SectionItemView { - IntSettingRow( - stringResource(MR.strings.network_option_ping_count), networkSMPPingCount, - listOf(1, 2, 3, 5, 8), "" - ) - } - SectionItemView { - EnableKeepAliveSwitch(networkEnableKeepAlive) - } - if (networkEnableKeepAlive.value) { + SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { SectionItemView { - IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel) + TimeoutSettingRow( + stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, + listOf(10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000), secondsLabel + ) } SectionItemView { - IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel) + TimeoutSettingRow( + stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, + listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel + ) } SectionItemView { - IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "") + // can't be higher than 130ms to avoid overflow on 32bit systems + TimeoutSettingRow( + stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, + listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel + ) } - } else { + // SectionItemView { + // IntSettingRow( + // stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency, + // listOf(1, 2, 4, 8, 12, 16, 24), "" + // ) + // } SectionItemView { - Text("TCP_KEEPIDLE", color = MaterialTheme.colors.secondary) + TimeoutSettingRow( + stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval, + listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel + ) } SectionItemView { - Text("TCP_KEEPINTVL", color = MaterialTheme.colors.secondary) + IntSettingRow( + stringResource(MR.strings.network_option_ping_count), networkSMPPingCount, + listOf(1, 2, 3, 5, 8), "" + ) } SectionItemView { - Text("TCP_KEEPCNT", color = MaterialTheme.colors.secondary) + EnableKeepAliveSwitch(networkEnableKeepAlive) + } + if (networkEnableKeepAlive.value) { + SectionItemView { + IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel) + } + SectionItemView { + IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel) + } + SectionItemView { + IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "") + } + } else { + SectionItemView { + Text("TCP_KEEPIDLE", color = MaterialTheme.colors.secondary) + } + SectionItemView { + Text("TCP_KEEPINTVL", color = MaterialTheme.colors.secondary) + } + SectionItemView { + Text("TCP_KEEPCNT", color = MaterialTheme.colors.secondary) + } } } } @@ -309,7 +326,7 @@ private fun SMPProxyModePicker( ) { val density = LocalDensity.current val values = remember { - SMPProxyMode.values().map { + SMPProxyMode.entries.map { when (it) { SMPProxyMode.Always -> ValueTitleDesc(SMPProxyMode.Always, generalGetString(MR.strings.network_smp_proxy_mode_always), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_always_description), density)) SMPProxyMode.Unknown -> ValueTitleDesc(SMPProxyMode.Unknown, generalGetString(MR.strings.network_smp_proxy_mode_unknown), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_unknown_description), density)) @@ -344,7 +361,7 @@ private fun SMPProxyFallbackPicker( ) { val density = LocalDensity.current val values = remember { - SMPProxyFallback.values().map { + SMPProxyFallback.entries.map { when (it) { SMPProxyFallback.Allow -> ValueTitleDesc(SMPProxyFallback.Allow, generalGetString(MR.strings.network_smp_proxy_fallback_allow), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_description), density)) SMPProxyFallback.AllowProtected -> ValueTitleDesc(SMPProxyFallback.AllowProtected, generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected_description), density)) @@ -537,6 +554,7 @@ fun PreviewAdvancedNetworkSettingsLayout() { sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) }, smpProxyFallback = remember { mutableStateOf(SMPProxyFallback.Allow) }, + smpWebPortServers = remember { mutableStateOf(SMPWebPortServers.Preset) }, networkTCPConnectTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeoutPerKb = remember { mutableStateOf(10_000) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 835e01ec27..98f671ddc4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -22,8 +22,12 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.* @@ -180,7 +184,14 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { @Composable fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) { SectionItemView( - click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } }, + click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + UsageConditionsView( + currUserServers, + userServers, + close, + rhId + ) + } }, ) { Text( stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted), @@ -733,17 +744,43 @@ fun UsageConditionsView( } } + @Composable + fun ConditionsDiffButton() { + val uriHandler = LocalUriHandler.current + val commit = chatModel.conditions.value.currentConditions.conditionsCommit + Column ( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .clip(shape = CircleShape) + .clickable { + val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit" + uriHandler.openUriCatching(commitUrl) + } + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text(stringResource(MR.strings.operator_open_changes), color = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Icon(painterResource(MR.images.ic_outbound), contentDescription = null, tint = MaterialTheme.colors.primary) + } + } + } + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) when (val conditionsAction = chatModel.conditions.value.conditionsAction) { is UsageConditionsAction.Review -> { + AppBarTitle(stringResource(MR.strings.operator_updated_conditions), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) if (conditionsAction.operators.isNotEmpty()) { ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) } Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { ConditionsTextView(rhId) } - AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2) + AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, DEFAULT_PADDING_HALF) if (conditionsAction.deadline != null) { SectionTextFooter( text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))), @@ -751,9 +788,12 @@ fun UsageConditionsView( ) Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) } + ConditionsDiffButton() + Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) } is UsageConditionsAction.Accepted -> { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) if (conditionsAction.operators.isNotEmpty()) { ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) } @@ -763,6 +803,7 @@ fun UsageConditionsView( } else -> { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { ConditionsTextView(rhId) } @@ -771,6 +812,18 @@ fun UsageConditionsView( } } +@Composable +fun SimpleConditionsView( + rhId: Long? +) { + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } +} + @Composable fun ServersErrorFooter(errStr: String) { Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index bebc96a28c..8626fd3143 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -189,7 +189,7 @@ fun CustomServer( if (valid.value) { SectionDividerSpaced() SectionView(stringResource(MR.strings.smp_servers_add_to_another_device).uppercase()) { - QRCode(serverAddress.value) + QRCode(serverAddress.value, small = true) } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 4c781f0aab..4bb2244785 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -36,13 +36,13 @@ سيبقى جميع أعضاء المجموعة على اتصال. السماح باختفاء الرسائل فقط إذا سمحت جهة اتصالك بذلك. السماح بحذف الرسائل بشكل لا رجوع فيه فقط إذا سمحت لك جهة الاتصال بذلك. (24 ساعة) - المشرف + المُدير أضِف ملف التعريف السماح بإرسال رسائل مباشرة إلى الأعضاء. قبول التخفي أضِف رسالة ترحيب أضف الخوادم عن طريق مسح رموز QR. - يمكن للمشرفين إنشاء روابط للانضمام إلى المجموعات. + يمكن للمُدراء إنشاء روابط للانضمام إلى المجموعات. قبول طلب الاتصال؟ سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك. مكالمة مقبولة @@ -51,12 +51,12 @@ يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. أجب الاتصال - دائِماً + دائمًا السماح بإرسال رسائل تختفي. السماح بإرسال رسائل صوتية. تطبيق ثانوي إضافي - السماح لجهات اتصالك بإضافة ردود الفعل الرسالة. + السماح لجهات اتصالك بإضافة ردود الفعل للرسالة. السماح لجهات اتصالك بالاتصال بك. السماح بردود الفعل على الرسائل. يتم مسح جميع البيانات عند إدخالها. @@ -64,11 +64,11 @@ السماح لجهات اتصالك بإرسال رسائل تختفي. اسمح بالرسائل الصوتية فقط إذا سمحت جهة اتصالك بذلك. رمز مرور التطبيق - دائِماً مُتاح + يعمل دائمًا يمكن للتطبيق استلام الإشعارات فقط عند تشغيله، ولن يتم بدء تشغيل أي خدمة في الخلفية السماح بالرسائل الصوتية؟ ستبقى جميع جهات اتصالك متصلة. - استخدم التتابع دائمًا + استخدم الموجه دائمًا النسخ الاحتياطي لبيانات التطبيق حُذفت جميع بيانات التطبيق. السماح بحذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) @@ -121,7 +121,7 @@ مصادقة 1 دقيقة 30 ثانية - إلغاء الرسالة المباشرة + ألغِ الرسالة الحيّة إلغاء لكل جهة اتصال وعضو في المجموعة\n. الرجاء ملاحظة: إذا كان لديك العديد من الاتصالات، فقد يكون استهلاك البطارية وحركة المرور أعلى بكثير وقد تفشل بعض الاتصالات.]]> جارٍ الاتصال… @@ -174,7 +174,7 @@ تغيير عنوان الاستلام؟ نٌسخت إلى الحافظة مسح - مسح الدردشة + امسح الدردشة إنشاء عنوان الدردشات تأكيد عبارة المرور الجديدة… @@ -196,7 +196,7 @@ إنشاء مجموعة سرية خطأ في إحباط تغيير العنوان تفعيل قفل SimpleX - تأكد من بيانات الاعتماد الخاصة بك + تأكد من بيانات اعتمادك أنشئ عنوان SimpleX متابعة تحدث مع المطورين @@ -286,7 +286,7 @@ الاتصال %1$d انشأت الاتصال مكالمة جارية… - عَمِّ + تشفير أدخل عبارة المرور… المنشئ خطأ في إضافة الأعضاء @@ -308,7 +308,7 @@ تواصل عبر الرابط / رمز QR إنشاء رابط دعوة لمرة واحدة تحقق من عنوان الخادم وحاول مرة أخرى. - مسح التَحَقُّق + امسح التحقُّق أنشئ عنوانًا للسماح للأشخاص بالتواصل معك. أدخل الخادم يدويًا ملون @@ -464,8 +464,8 @@ سيتم استلام الملف عندما يكتمل جهة اتصالك من رفعِها. المساعدة الملف: %s - إصلاح - إصلاح الاتصال + أصلح + أصلح الاتصال إصلاح الاتصال؟ الإصلاح غير مدعوم من قبل أعضاء المجموعة يمكن للأعضاء إرسال الملفات والوسائط. @@ -567,7 +567,7 @@ مدعو %1$s مدعو عبر رابط المجموعة الدردشة غير صالحة - حي + حيّ رابط اتصال غير صالح الملف كبير! معرفة المزيد @@ -577,7 +577,7 @@ تأكيد الترحيل غير صالح مدعو رابط معاينة الصورة - رسالة مباشرة! + رسالة حيّة! مائل لنتحدث في SimpleX Chat قفل بعد @@ -585,14 +585,14 @@ انتهت صلاحية الدعوة! انضمام إلى المجموعة؟ الانضمام المتخفي - الانضمام إلى المجموعة + جارِ الانضمام إلى المجموعة غادِر مغادرة المجموعة؟ غادر - إيصالات التسليم معطلة! + إيصالات التسليم مُعطَّلة! تعطيل رسائل تختفي - عٌطل مصادقة الجهاز. جاري إيقاف تشغيل قفل SimpleX. + استيثاق الجهاز مُعطَّل. جارِ إيقاف تشغيل قفل SimpleX. %d شهر %d أسابيع %d أسبوع @@ -610,11 +610,11 @@ يختفي في: %s الرسائل المختفية ممنوعة في هذه الدردشة. مُعمّى بين الطريفين - حُرر + حُرّر الرجوع إلى إصدار سابق وفتح الدردشة رسائل مباشرة الرسائل المختفية ممنوعة. - تحرير ملف تعريف المجموعة + حرّر ملف تعريف المجموعة لا تُظهر مرة أخرى الجهاز %d أسبوع @@ -625,7 +625,7 @@ %d ساعة %d ساعات %d شهر - تحرير الصورة + حرّر الصورة %d ملف/ات بإجمالي الحجم %s %d ثانية جهات الاتصال @@ -695,7 +695,7 @@ خطأ في إرسال الرسالة خطأ في الانضمام إلى المجموعة خطأ في مزامنة الاتصال - تسجيل الدخول باستخدام بيانات الاعتماد الخاصة بك + سجّل الدخول باستخدام بيانات اعتمادك خطأ في حفظ الملف علّم غير مقروءة تأكد من أن عناوين خادم WebRTC ICE بالتنسيق الصحيح، وأن تكون مفصولة بأسطر وليست مكررة. @@ -704,7 +704,7 @@ خطأ في حفظ ملف تعريف المجموعة رسالة نصية ردود فعل الرسائل - سيتم وضع علامة على الرسالة للحذف. سيتمكن المستلم/مون من الكشف عن هذه الرسالة. + سيتم وضع علامة على الرسالة للحذف. سيتمكن المُستلم/ون من الكشف عن هذه الرسالة. سيتم حذف الرسالة - لا يمكن التراجع عن هذا! خطأ في تسليم الرسالة الشبكة والخوادم @@ -712,7 +712,7 @@ فتح في تطبيق الجوال، ثم انقر فوق اتصال في التطبيق.]]> تحت الإشراف في: %s ردود الفعل الرسائل ممنوعة في هذه الدردشة. - مُشرف بواسطة %s + أُشرف بواسطة %s من المرجح أن جهة الاتصال هذه قد حذفت الاتصال بك. ردود فعل الرسائل مكالمة فائتة @@ -729,9 +729,7 @@ إيصالات تسليم الرسائل! دقائق شهور - - توصيل رسائل أكثر استقرارًا. -\n- مجموعات أفضل قليلاً. -\n- و اكثر! + - توصيل رسائل أكثر استقرارًا.\n- مجموعات أفضل قليلاً.\n- و اكثر! حالة الشبكة كتم ردود الفعل الرسائل ممنوعة. @@ -757,7 +755,7 @@ سيتم استخدام مضيفات البصل عند توفرها. لن يتم استخدام مضيفات البصل. لم تٌحدد جهات اتصال - يمكن للمشرف الآن:\n- حذف رسائل الأعضاء.\n- تعطيل الأعضاء (دور المراقب) + يمكن للمُدراء الآن:\n- حذف رسائل الأعضاء.\n- تعطيل الأعضاء (دور المراقب) خدمة الإشعار غير مفعّل` مفعل @@ -777,18 +775,17 @@ يمكن إرسال 10 فيديوهات فقط في نفس الوقت رابط دعوة لمرة واحدة لا - سوف تكون مضيفات البصل مطلوبة للاتصال. -\nيُرجى ملاحظة: أنك لن تتمكن من الاتصال بالخوادم بدون عنوان onion. + ستكون مضيفات البصل مطلوبة للاتصال. \nيُرجى ملاحظة: أنك لن تتمكن من الاتصال بالخوادم بدون عنوان onion. اسم عرض جديد: عبارة مرور جديدة… قيد الانتظار - كلمة المرور مطلوبة + عبارة المرور مطلوبة ألصِق الرابط الذي استلمته فقط مالكي المجموعة يمكنهم تفعيل الملفات والوسائط. فقط مالكي المجموعة يمكنهم تفعيل الرسائل الصوتية. (يخزن فقط بواسطة أعضاء المجموعة) - كلمة المرور - تم تعيين كلمة المرور! + رمز المرور + عيّنت رمز المرور! المالك فقط جهة اتصالك يمكنها إرسال رسائل تختفي. جهة اتصالك فقط يمكنها إضافة ردود الفعل على الرسالة @@ -796,26 +793,26 @@ جهة اتصالك فقط يمكنها حذف الرسائل بشكل لا رجعة فيه (يمكنك تعليم الرسالة للحذف). (24 ساعة) أنت فقط يمكنك إرسال رسائل صوتية. افتح - لم يتم تغيير كلمة المرور! - تم تغيير كلمة المرور + لم يتم تغيير رمز المرور! + تم تغيير رمز المرور جارِ فتح قاعدة البيانات… جهة اتصالك فقط يمكنها إرسال رسائل صوتية. ألصق - كلمة المرور غير موجودة في مخزن المفاتيح، يرجى إدخالها يدوياً. قد يحدث هذا إذا قمت باستعادة ملفات التطبيق باستخدام أداة استرجاع بيانات. إذا لم يكن الأمر كذلك، تواصل مع المبرمجين رجاء + لم يتم العثور على عبارة المرور في Keystore، يُرجى إدخالها يدويًا. ربما حدث هذا إذا استعدت بيانات التطبيق باستخدام أداة النسخ الاحتياطي. إذا لم يكن الأمر كذلك، يُرجى التواصل مع المطورين. افتح الدردشة - فتح الرابط في المتصفح قد يقلل خصوصية وحماية اتصالك. الروابط غير الموثوقة من SimpleX ستكون باللون الأحمر + قد يؤدي فتح الرابط في المتصفح إلى تقليل خصوصية الاتصال وأمانه. ستظهر روابط SimpleX غير الموثوقة باللون الأحمر. أنت فقط يمكنك إضافة ردود الفعل على الرسالة. أنت فقط يمكنك حذف الرسائل بشكل لا رجعة فيه (يمكن للمستلم تعليمها للحذف). (24 ساعة) أنت فقط يمكنك إرسال رسائل تختفي أنت فقط يمكنك إجراء المكالمات. فقط جهة اتصالك يمكنها إجراء المكالمات. افتح وحدة تحكم الدردشة - إدخال كلمة المرور - افتح SimpleX Chat للرد على المكالمة + إدخال رمز المرور + افتح SimpleX Chat لقبول المكالمة يمكن لأي شخص استضافة الخوادم. كلمة المرور للإظهار ندّ لِندّ - أنت تقرر من يمكنه الاتصال. + أنت تقرر مَن يمكنه الاتصال. مكالمة قيد الانتظار تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل. صفّر الألوان @@ -879,7 +876,7 @@ حٌديثت السجل في حٌديثت السجل في: %s استعادة - يرى المستلمون التحديثات أثناء كتابتها. + يرى المُستلمون التحديثات أثناء كتابتها. استلمت، ممنوع حفظ سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. @@ -907,7 +904,7 @@ منع إرسال الرسائل التي تختفي. الاحتفاظ بمسودة الرسالة الأخيرة، مع المرفقات. أسماء ملفات خاصة - حماية ملفات تعريف الدردشة الخاصة بك بكلمة مرور! + احمِ ملفات تعريف دردشتك بكلمة مرور! رُفضت المكالمة حماية شاشة التطبيق أُزيلت @@ -915,7 +912,7 @@ معاينة من المحتمل أن الملف المرجعي للشهادة في عنوان الخادم غير صحيح يتم استلام الرسائل… - يُرجى الاتصال بمشرف المجموعة. + يُرجى الاتصال بمُدير المجموعة. أعد التفاوض إعادة تفاوض التعمية سحب وصول الملف @@ -935,12 +932,12 @@ %s في %s حفظ ملف المجموعة ثانوي - كلمة مرور التدمير الذاتي + رمز المرور للتدمير الذاتي إرسال الملفات غير مدعوم بعد - قام المرسل بإلغاء إرسال الملف + أُلغيَ المرسل نقل الملف. (امسح أو ألصق من الحافظة) ثانية - قد يكون المرسل قد ألغى طلب الاتصال + ربما حذف المرسل طلب الاتصال. مسح رمز QR أرسل لنا بريداً مسح رمز الأمان من تطبيق جهة الاتصال @@ -948,65 +945,65 @@ سيتم إيقاف إرسال الملف. إرسال رسالة إرسال - إرسال رسالة حية + أرسل رسالة حيّة فشلت تجربة الخادم! - حفظ كلمة المرور في مخزن المفاتيح + احفظ عبارة المرور في Keystore أرسل رسالة مباشرة إرسال عبر الخوادم تقييم الأمان الرسائل المرسلة سيتم حذفها بعد المدة المحدّدة. تعيين رسالة تظهر للأعضاء الجدد! - تعيين كلمة المرور + عيّن رمز المرور تم إرساله في: %s %s (الحالي) رسالة مرسلة عيّن تفضيلات المجموعة - عيينها بدلا من توثيق النظام + عيّنها بدلاً من استيثاق النظام. مشاركة إرسال - حفظ كلمة المرور وفتح الدردشة - حدد جهات اتصال + احفظ عبارة المرور وافتح الدردشة + حدد جهات الاتصال تعيين يوم واحد ثواني رسالة مرسلة أرسل رسالة تختفي - حفظ كلمة مرور الحساب + حفظ كلمة مرور ملف التعريف تدمير ذاتي - مسح الكود - إرسال أسئلة وأفكار + مسح الرمز + أرسل أسئلة وأفكار مشاركة العنوان مع جهات الاتصال؟ مشاركة العنوان حفظ رسالة الترحيب؟ - حفظ السيرفرات - أرسل تقارير الاستلام إلى - إرسال تقارير الاستلام معطل لـ %d جهة اتصال. - إرسال تقارير الاستلام مفعل لـ %d جهة اتصال - تعيين كلمة المرور للتصدير + احفظ الخوادم + أرسل إيصالات التسليم إلى + إرسال الإيصالات مُعطَّل لـ %d جهة اتصال. + إرسال الإيصالات مفعّل لـ %d جهة اتصال + عيّن عبارة المرور للتصدير تم تغيير رمز الأمان - تقارير الارسال + إيصالات الإرسال تم إرساله في حدد - إرسال تقارير الاستلام سيتم تفعيله لجميع جهات الاتصال. - سيتم تفعيل إرسال تقارير الاستلام لجميع جهات الاتصال ذات حسابات دردشة ظاهرة + سيتم تفعيل إرسال إيصالات التسليم لجميع جهات الاتصال. + سيتم تفعيل إرسال إيصالات التسليم لجميع جهات الاتصال في جميع ملفات تعريف الدردشة المرئية. قائمة انتظار آمنة فشل الإرسال - تم الإرسال + أُرسلت تعيين اسم جهة الاتصال… - إرسال رسالة حية - سيتم تحديثها للمستلم مع كتابتك لها + أرسل رسالة حيّة - سيتم تحديثها للمُستلم مع كتابتك لها تعيين اسم جهة الاتصال الإعدادات حفظ الخوادم؟ مسح رمز QR الخادم رمز الأمان - حفظ الإعدادات؟ + حفظ التفضيلات؟ حفظ الإعدادات؟ سري - كلمة مرور التدمير الذاتي - تم تغيير كلمة مرور التدمير الذاتي! - تم تفعيل كلمة مرور التدمير الذاتي + رمز المرور للتدمير الذاتي + تغيّرت رمز المرور للتدمير الذاتي! + فعّلت رمز المرور للتدمير الذاتي! الإعدادات - دعوة لمرة واحدة SimpleX + دعوة SimpleX لمرة واحدة عرض جهة الاتصال والرسالة قفل SimpleX لم يتحقق من %s @@ -1033,7 +1030,7 @@ إيقاف عرض المعاينة السماعة متوقفة - وضع قفل SimpleX + SimpleX وضع القفل مشاركة الرابط الرسائل التي تم تخطيها عنوان SimpleX @@ -1069,7 +1066,7 @@ إعدادات وكيل SOCKS إيقاف التشغيل السماعة قيد التشغيل - إرسال + أرسل النظام السمة لبدء محادثة جديدة @@ -1077,7 +1074,7 @@ لامركزية بالكامل – مرئية للأعضاء فقط. النظام فشل الاختبار في الخطوة %s. - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! يجلب التطبيق الرسائل الجديدة بشكل دوري - يستخدم نسبة قليلة من البطارية يوميًا. لا يستخدم التطبيق إشعارات الدفع - لا يتم إرسال البيانات من جهازك إلى الخوادم. سيتم إلغاء الاتصال الذي قبلته! لن تتمكن جهة الاتصال التي شاركت هذا الرابط معها من الاتصال! @@ -1085,26 +1082,26 @@ لحماية خصوصيتك، يستخدم SimpleX معرّفات منفصلة لكل جهة اتصال لديك. لحماية معلوماتك، فعّل قفل SimpleX \nسيُطلب منك إكمال المصادقة قبل تفعيل هذه الميزة. عزل النقل - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! دعم البلوتوث وتحسينات أخرى. - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! يتم تشغيل SimpleX في الخلفية بدلاً من استخدام إشعارات push.]]> انقر لبدء محادثة جديدة (للمشاركة مع جهة اتصالك) للتواصل عبر الرابط للاتصال، يمكن لجهة الاتصال مسح رمز QR أو استخدام الرابط في التطبيق. - خوادم الاختبار + اختبر الخوادم لا معرّفات مُستخدم دعم SIMPLEX CHAT تبديل العنوان الرئيسي سيتم وضع علامة على الرسالة على أنها تحت الإشراف لجميع الأعضاء. انقر للانضمام - للكشف عن ملف تعريفك المخفي، أدخل كلمة مرور كاملة في حقل البحث في صفحة ملفات تعريف الدردشة الخاصة بك. + للكشف عن ملف تعريفك المخفي، أدخل كلمة مرور كاملة في حقل البحث في صفحة ملفات تعريف دردشتك. انقر للانضمام إلى وضع التخفي النظام السمات - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! قاعدة البيانات لا تعمل بشكل صحيح. انقر لمعرفة المزيد ألوان الواجهة انقر لتنشيط ملف التعريف. @@ -1115,7 +1112,7 @@ انتهت مهلة اتصال TCP لحماية المنطقة الزمنية، تستخدم ملفات الصور / الصوت التوقيت العالمي المنسق (UTC). فقدنا القراد الثاني! ✅ - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! لم تكتمل محاولة تغيير عبارة مرور قاعدة البيانات. لاستلام الإشعارات، يُرجى إدخال عبارة مرور قاعدة البيانات مصادقة النظام @@ -1126,7 +1123,7 @@ مقاطع الفيديو كثيرة! زر النقر شكرًا لك على تثبيت SimpleX Chat! - خادم الاختبار + اختبر الخادم تجزئة الرسالة السابقة مختلفة. معرف الرسالة التالية غير صحيح (أقل أو يساوي السابق). \nيمكن أن يحدث ذلك بسبب بعض العلل أو عندما يُخترق الاتصال. @@ -1144,8 +1141,8 @@ أنت لقد شاركت رابط لمرة واحدة سيتم إرسال ملف التعريفك إلى جهة الاتصال التي استلمت منها هذا الرابط. - سوف تتصل بجميع أعضاء المجموعة. - ملفات تعريف الدردشة الخاصة بك + ستتصل بجميع أعضاء المجموعة. + ملفات تعريف دردشتك عنوان SimpleX الخاص بك خوادم SMP الخاصة بك عندما يكون التطبيق قيد التشغيل @@ -1160,8 +1157,8 @@ تحقق من رمز الأمان رسائل صوتية عندما يطلب الأشخاص الاتصال، يمكنك قبوله أو رفضه. - سوف تكون متصلاً بالمجموعة عندما يكون جهاز مضيف المجموعة متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! - سوف تكون متصلاً عندما يتم قبول طلب اتصالك، يُرجى الانتظار أو التحقق لاحقًا! + ستكون متصلاً بالمجموعة عندما يكون جهاز مضيف المجموعة متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! + ستكون متصلاً عندما يتم قبول طلب اتصالك، يُرجى الانتظار أو التحقق لاحقًا! تستخدم خوادم SimpleX Chat. استخدم وكيل SOCKS استخدم مضيفي onion. @@ -1203,21 +1200,21 @@ عبر رابط لمرة واحدة مكالمة الفيديو ليست مُعمّاة بين الطريفين غيّرتَ العنوان - سوف تكون متصلاً عندما يكون جهاز جهة اتصالك متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! + ستكون متصلاً عندما يكون جهاز جهة اتصالك متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! غادرت - يجب عليك استخدام أحدث إصدار من قاعدة بيانات الدردشة الخاصة بك على جهاز واحد فقط، وإلا فقد تتوقف عن تلقي الرسائل من بعض جهات الاتصال. + يجب عليك استخدام أحدث إصدار من قاعدة بيانات دردشتك على جهاز واحد فقط، وإلا فقد تتوقف عن تلقي الرسائل من بعض جهات الاتصال. سيتم استلام الفيديو عندما تكون جهة اتصالك متصلة بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! يمكنك مشاركة هذا العنوان مع جهات اتصالك للسماح لهم بالاتصال بـ%s. أُزيلت %1$s - تحديث - قاعدة بيانات الدردشة الخاصة بك غير مُعمّاة - عيّن عبارة مرور لحمايتها. + حدّث + قاعدة بيانات دردشتك غير مُعمّاة - عيّن عبارة مرور لحمايتها. عبارة مرور قاعدة بيانات خاطئة سيتم إرسال ملف تعريف الدردشة الخاص بك إلى أعضاء المجموعة مرحبًا! %1$s يريد الاتصال بك! خوادم ICE الخاصة بك خصوصيتك - حدثت ملف تعريف المجموعة + حُدثت ملف تعريف المجموعة أنت: %1$s تحديث تحديث إعدادات الشبكة؟ @@ -1235,7 +1232,7 @@ سيتم حذف قاعدة بيانات الدردشة الحالية واستبدالها بالقاعدة المستوردة. \nلا يمكن التراجع عن هذا الإجراء - سيتم فقد ملف التعريف وجهات الاتصال والرسائل والملفات الخاصة بك بشكل نهائي. تحديث عبارة مرور قاعدة البيانات - سوف تتوقف عن تلقي الرسائل من هذه المجموعة. سيتم الاحتفاظ بسجل الدردشة. + ستتوقف عن تلقي الرسائل من هذه المجموعة. سيتم الاحتفاظ بسجل الدردشة. أسابيع يمكنك إخفاء أو كتم ملف تعريف المستخدم - اضغط مطولاً للقائمة. ما هو الجديد @@ -1260,16 +1257,14 @@ استخدم للاتصالات الجديدة استخدم الخادم عنوان خادمك - قاعدة بيانات الدردشة الخاصة بك + قاعدة بيانات دردشتك أنت مدعو إلى المجموعة. انضم للتواصل مع أعضاء المجموعة. - لقد انضممت إلى هذه المجموعة. الاتصال بدعوة عضو المجموعة. + لقد انضممت إلى هذه المجموعة. جارِ الاتصال بدعوة عضو المجموعة. غيّرتَ العنوان ل%s إلغاء إخفاء ملف تعريف الدردشة الرسائل الصوتية ممنوعة في هذه الدردشة. مقاطع فيديو وملفات تصل إلى 1 جيجا بايت - - رسائل صوتية تصل إلى 5 دقائق. -\n- الوقت المخصص لتختفي. -\n- تحرير التاريخ. + - رسائل صوتية تصل إلى 5 دقائق.\n- الوقت المخصص لتختفي.\n- تحرير التاريخ. يمكنك تفعيلة لاحقًا عبر الإعدادات يمكنك تفعيلها لاحقًا عبر إعدادات الخصوصية والأمان للتطبيق. عبر رابط المجموعة @@ -1350,12 +1345,12 @@ سيتم تحديث عبارة مرور تعمية قاعدة البيانات وتخزينها في الإعدادات. هل تريد إزالة عبارة المرور من الإعدادات؟ استخدم عبارة مرور عشوائية - حفظ عبارة المرور في الإعدادات - إعداد كلمة المرور لقاعدة البيانات - تعيين عبارة مرور قاعدة البيانات + احفظ عبارة المرور في الإعدادات + إعداد عبارة المرور لقاعدة البيانات + عيّن عبارة مرور قاعدة البيانات افتح مجلد قاعدة البيانات سيتم تخزين عبارة المرور في الإعدادات كنص عادي بعد تغييرها أو إعادة تشغيل التطبيق. - يُخزين عبارة المرور في الإعدادات كنص عادي. + عبارة المرور مخزنة في الإعدادات كنص عادي. يُرجى الملاحظة: يتم توصيل مرحلات الرسائل والملفات عبر وكيل SOCKS. تستخدم المكالمات وإرسال معاينات الروابط الاتصال المباشر.]]> عَمِّ الملفات المحلية عَمِّ الملفات والوسائط المخزنة @@ -1365,9 +1360,7 @@ اكتشاف والانضمام إلى المجموعات العربية والبلغارية والفنلندية والعبرية والتايلاندية والأوكرانية - شكرًا للمستخدمين و Weblate. إنشاء ملف تعريف جديد في تطبيق سطح المكتب. 💻 - - الاتصال بخدمة الدليل (تجريبي)! -\n- إيصالات التسليم (ما يصل إلى 20 عضوا). -\n- أسرع وأكثر استقرارًا. + - الاتصال بخدمة الدليل (تجريبي)!\n- إيصالات التسليم (ما يصل إلى 20 عضوا).\n- أسرع وأكثر استقرارًا. افتح حدث خطأ أثناء إنشاء جهة اتصال للعضو أرسل رسالة مباشرة للاتصال @@ -1444,7 +1437,7 @@ مجموعات التخفي %s و%s و%d عضو هذا الجهاز - %1$d من الرسائل يُشرف عليها بواسطة %2$s + %1$d من الرسائل أُشرف عليها بواسطة %2$s إلغاء حظر العضو %s قُطع اتصاله]]> في انتظار سطح المكتب… @@ -1482,7 +1475,7 @@ تحقق من الرمز مع سطح المكتب مسح رمز QR من سطح المكتب إلغاء الحظر - - إشعار اختياريًا جهات الاتصال المحذوفة. \n- أسماء الملفات التعريفية بمسافات. \n- و اكثر! + - إشعار اختياريًا جهات الاتصال المحذوفة.\n- أسماء الملفات التعريفية بمسافات.\n- و اكثر! مسار الملف غير صالح لقد طلبت بالفعل الاتصال عبر هذا العنوان! إظهار وحدة التحكم في نافذة جديدة @@ -1596,7 +1589,7 @@ إلغاء حظر العضو للجميع؟ إلغاء الحظر للجميع حدث خطأ أثناء حظر العضو للجميع - حُظر %d رسالة من قبل المشرف + حُظرت %d رسالة من قبل المُدير محظور %s أُلغيت حظر %s حظرت %s @@ -1604,8 +1597,8 @@ محظور حظر للجميع حظر العضو للجميع؟ - محظور من قبل المشرف - محظور من قبل المشرف + محظور من قبل المُدير + محظور من قبل المُدير الرسالة كبيرة جدًا رسالة الترحيب طويلة جدًا ترحيل قاعدة البيانات قيد التقدم. @@ -1616,10 +1609,10 @@ متصفح الويب الافتراضي مطلوب للمكالمات. يُرجى تضبيط المتصفح الافتراضي في النظام، ومشاركة المزيد من المعلومات مع المطورين. حدث خطأ أثناء فتح المتصفح أرشفة و رفع - يمكن للمشرفين حظر عضو للجميع. + يمكن للمُدراء حظر عضو للجميع. ترحيل بيانات التطبيق جارِ أرشفة قاعدة البيانات - سيتم تعمية جميع جهات الاتصال والمحادثات والملفات الخاصة بك بشكل آمن ورفعها في أجزاء إلى مُرحلات XFTP التي ضُبطت. + جميع جهات الاتصال، المحادثات والملفات الخاصة بك سيتم تشفيرها بأمان ورفعها على شكل أجزاء إلى موجهات XFTP المُعدة. طبّق يُرجى ملاحظة: استخدام نفس قاعدة البيانات على جهازين سيؤدي إلى كسر فك تعمية الرسائل من اتصالاتك، كحماية أمنية.]]> تحذير: سيتم حذف الأرشيف.]]> @@ -1715,7 +1708,7 @@ كل الأعضاء يمكن للأعضاء إرسال روابط SimpleX. روابط SimpleX محظورة. - المشرفين + المُدراء مفعّل لـ المالكون الملفات والوسائط غير مسموح بها @@ -1742,7 +1735,7 @@ إدارة الشبكة اتصال شبكة أكثر موثوقية. صور ملف التعريف - شكل الصور التعريفية + شكّل الصور التعريفية واجهة المستخدم الليتوانية مربع أو دائرة أو أي شيء بينهما. عنوان الخادم غير متوافق مع إعدادات الشبكة. @@ -1850,8 +1843,7 @@ لا يمكن إرسال الرسالة تفضيلات الدردشة المحدّدة تحظر هذه الرسالة. التفاصيل - بدءًا من %s. -\nجميع البيانات خاصة بجهازك. + بدءًا من %s.\nجميع البيانات خاصة على جهازك. أرسلت الإجمالي الحجم الملفات المرفوعة @@ -1861,14 +1853,14 @@ إصدار الخادم غير متوافق مع تطبيقك: %1$s. العضو غير نشط رسالة محوّلة - لا يوجد اتصال مباشر حتى الآن، يتم تحويل من قِبل المشرف. + لا يوجد اتصال مباشر حتى الآن، يتم تحويل من قِبل المُدير. امسح / ألصِق الرابط خوادم SMP المهيأة خوادم SMP أخرى خوادم XFTP المهيأة خوادم XFTP أخرى أظهِر النسبة المئوية - مُعطّل + مُعطَّل مستقرّ يتوفر تحديث: %s التمس التحديثات @@ -1879,7 +1871,7 @@ تذكر لاحقا تخطي هذه النسخة أُلغيت تنزيل التحديث - مُعطّل + مُعطَّل غير نشط معلومات الخوادم عرض المعلومات ل @@ -1916,7 +1908,7 @@ أخرى موّكل مؤمن - أرسِل الأخطاء + أرسل الأخطاء أُرسلت مباشرةً مُرسَل عبر الوكيل مشترك @@ -1976,9 +1968,9 @@ تمويه الوسائط متوسط ناعم - المكالمة + مكالمة اتصل - الرسالة + مراسلة افتح بحث الإعدادات @@ -2009,7 +2001,7 @@ يجب عليك السماح لجهات اتصالك بالاتصال حتى تتمكن من الاتصال بها. يُرجى الطلب من جهة اتصالك تفعيل المكالمات. حذف %d رسائل الأعضاء؟ - سيتم وضع علامة على الرسائل للحذف. سيتمكن المستلم/(المستلمون) من الكشف عن هذه الرسائل. + سيتم وضع علامة على الرسائل للحذف. سيتمكن المُستلم/(المُستلمون) من الكشف عن هذه الرسائل. حدد سيتم حذف الرسائل لجميع الأعضاء. سيتم وضع علامة على الرسائل على أنها تحت الإشراف لجميع الأعضاء. @@ -2049,15 +2041,14 @@ صفّر كافة التلميحات يُرجى التأكد من أن رابط SimpleX صحيح. الرابط غير صالح - %1$d خطأ في الملف: -\n%2$s + %1$d خطأ في الملف:\n%2$s فشل تنزيل %1$d ملف/ات. لم يتم تنزيل %1$d ملف/ات. نزّل شارك ملف التعريف استخدم بيانات اعتماد الوكيل المختلفة لكل اتصال. اسم المستخدم - قد يتم إرسال بيانات الاعتماد الخاصة بك غير مُعمَّاة. + قد يتم إرسال بيانات اعتمادك غير مُعمَّاة. خطأ في حفظ الوكيل إزالة الأرشيف؟ وضع النظام @@ -2066,7 +2057,7 @@ استخدم بيانات اعتماد عشوائية قاعدة بيانات الدردشة حُذف %1$d ملف/ات. - لا يزال يتم تنزيل %1$d ملفًا. + لا يزال يتم تنزيل %1$d ملف/ات. لا تستخدم بيانات الاعتماد مع الوكيل. خطأ في تحويل الرسائل خطأ في تبديل ملف التعريف @@ -2128,7 +2119,7 @@ يمكنك تضبيط المُشغلين في إعدادات الشبكة والخوادم. حدّث تابع - قُبل الشروط + الشروط المتفق عليها راجع الشروط الخوادم المُعدة مسبقًا سيتم قبول الشروط تلقائيًا للمُشغلين المفعّلين في: %s. @@ -2154,7 +2145,7 @@ %s.]]> أُضيفت خوادم الوسائط والملفات الشروط المفتوحة - الخوادم الخاصة بالملفات الجديدة لملف الدردشة الحالي الخاص بك + الخوادم الخاصة بالملفات الجديدة لملف دردشتك الحالي لإرسال خطأ في إضافة الخادم خطأ في تحديث الخادم @@ -2165,7 +2156,7 @@ أشرطة أدوات التطبيق تمويه الشفافية - فعّل flux + فعّل flux في إعدادات الشبكة والخوادم لتحسين خصوصية البيانات الوصفية. اللامركزية الشبكية المُشغل المُعد مسبقًا الثاني في التطبيق! لتحسين خصوصية البيانات الوصفية. @@ -2180,7 +2171,7 @@ لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: خطأ في قبول الشروط خطأ في حفظ الخوادم - على سبيل المثال، إذا تلقى أحد جهات اتصالك رسائل عبر خادم SimpleX Chat، فسوف يقوم تطبيقك بتسليمها عبر خادم Flux. + على سبيل المثال، إذا تلقى أحد جهات اتصالك رسائل عبر خادم SimpleX Chat، فسيقوم تطبيقك بتسليمها عبر خادم Flux. لا يوجد خوادم لتوجيه الرسائل الخاصة. لا يوجد خوادم رسائل. لا يوجد خوادم لاستقبال الملفات. @@ -2222,7 +2213,7 @@ أو استورد ملف الأرشيف لا توجد خدمة خلفية الإشعارات والبطارية - يمكن فقط لأصحاب الدردشة تغيير التفضيلات. + فقط مالكي الدردشة يمكنهم تغيير التفضيلات. الخصوصية لعملائك. الجوالات عن بُعد ادعُ للدردشة @@ -2236,9 +2227,153 @@ طلبت الاتصال يُرجى تقليل حجم الرسالة أو إزالة الوسائط ثم إرسالها مرة أخرى. يمكنك نسخ الرسالة وتقليل حجمها لإرسالها. - عندما يتم تفعيل أكثر من مُشغل واحد، لن يكون لدى أي منهم بيانات تعريفية لمعرفة من يتواصل مع من. + عندما يتم تفعيل أكثر من مُشغل واحد، لن يكون لدى أي منهم بيانات تعريفية لمعرفة مَن يتواصل مع مَن. سيتم تغيير الدور إلى %s. وسيتم إشعار الجميع في الدردشة. سيتم إرسال ملف تعريفك للدردشة إلى أعضاء الدردشة - سوف تتوقف عن تلقي الرسائل من هذه الدردشة. سيتم حفظ سجل الدردشة. + ستتوقف عن تلقي الرسائل من هذه الدردشة. سيتم حفظ سجل الدردشة. عن المُشغلين - \ No newline at end of file + توصلت SimpleX Chat وFlux إلى اتفاق لتضمين الخوادم التي تديرها Flux في التطبيق. + جارِ إعادة التفاوض على التعمية. + إصلاح الاتصال؟ + يتطلب الاتصال إعادة التفاوض على التعمية. + إصلاح + فعّل السجلات + خطأ في حفظ قاعدة البيانات + شطب + الاتصال غير جاهز. + القائمة + لا دردشات + لا توجد محادثات في القائمة %s. + لا توجد محادثات غير مقروءة + لم يتم العثور على أي محادثات + المفضلات + أضف القائمة + الكل + المجموعات + افتح باستخدام %s + أضف إلى القائمة + احذف + حذف القائمة؟ + حرّر + اسم القائمة... + يجب أن يكون اسم القائمة والرموز التعبيرية مختلفين لجميع القوائم. + احفظ القائمة + جهات الاتصال + خطأ في إنشاء قائمة الدردشة + الشركات + خطأ في تحميل قوائم الدردشة + سيتم إزالة جميع المحادثات من القائمة %s، وسيتم حذف القائمة + أنشئ قائمة + خطأ في تحديث قائمة الدردشة + الملحوظات + تغيير القائمة + تغيير الترتيب + خطأ في حفظ الإعدادات + خطأ في إنشاء بلاغ + أنت والمشرفون فقط هم من يرون ذلك + بلاغ مؤرشف + لا يراه إلا المُرسِل والمُشرفين + أرشف + أرشف البلاغ + احذف البلاغ + بلّغ + ملف تعريفي غير لائق + إزعاج (spam) + سبب آخر + البلاغات + 1 بلاغ + %d بلاغات + بلاغات الأعضاء + بلّغ عن المحتوى: سيراه مشرفو المجموعة فقط. + بلّغ عن أُخرى: سيراه مشرفو المجموعة فقط. + مشرف + بلاغ مؤرشف بواسطة %s + بلّغ عن ملف تعريف العضو: سيراه مشرفو المجموعة فقط. + انتهاك إرشادات المجتمع + محتوى غير لائق + بلّغ عن مخالفة: سيراه مشرفو المجموعة فقط. + بلّغ عن إزعاج (spam): سيراه مشرفو المجموعة فقط. + أرشفة البلاغ؟ + سبب الإبلاغ؟ + سيتم أرشفة البلاغ لك. + إزعاج (spam) + نعم + اسأل + لا + افتح الرابط + فتح رابط الويب؟ + حُظر الاتصال + افتح الروابط من قائمة الدردشة + المحتوى ينتهك شروط الاستخدام + حُظر الاتصال بواسطة مُشغل الخادم:\n%1$s. + حُظر الملف بواسطة مُشغل الخادم:\n%1$s. + الافتراضي %s + سنة واحدة + عطّل حذف الرسائل التلقائي + تعطيل حذف الرسائل التلقائي؟ + لن يتم حذف الرسائل الموجودة في هذه الدردشة أبدًا. + احذف رسائل الدردشة من جهازك. + عيّن اسم الدردشة… + لا يمكن التراجع عن هذا الإجراء - سيتم حذف الرسائل المُرسلة والمُستلمة في هذه الدردشة قبل التاريخ المحدّد. + تغيير حذف الرسائل التلقائي؟ + استخدم منفذ TCP %1$s عندما لا يتم تحديد أي منفذ. + استخدم منفذ الويب + منفذ TCP للمُراسلة + اكتم الكل + ذّكورات غير مقروءة + يمكنك ذكر ما يصل إلى %1$s من الأعضاء في الرسالة الواحدة! + السماح بالإبلاغ عن الرسائل إلى المشرفين. + منع الإبلاغ عن الرسائل للمشرفين. + أرشفة كافة البلاغات؟ + أرشف البلاغات + لكل المشرفين + لي + بلاغ: %s + يمكن للأعضاء الإبلاغ عن الرسائل إلى المشرفين. + سيتم أرشفة كافة البلاغات لك. + أرشفة %d بلاغ؟ + يُمنع الإبلاغ عن الرسائل في هذه المجموعة. + لا تفوت رسائل مهمة. + مساعدة المُدراء على إشراف مجموعاتهم. + أذكر الأعضاء 👋 + نظّم الدردشات في القوائم + أرسل بلاغات خاصة + اضبط انتهاء صلاحية الرسالة في الدردشات. + حذف أسرع من المجموعات. + أسماء ملفات الوسائط خاصة. + استلم إشعارًا عند ذكرك. + أداء مجموعات أفضل + خصوصية وأمان أفضل + إرسال أسرع للرسائل. + رُفض + رُفض + خطأ في قراءة عبارة مرور قاعدة البيانات + ينتظر + حُدثت الشروط + إزالة الأعضاء؟ + سيتم إخفاء جميع الرسائل الجديدة من هؤلاء الأعضاء! + سيتم إزالة الأعضاء من الدردشة - لا يمكن التراجع عن هذا! + إلغاء حظر الأعضاء للجميع؟ + حظر الأعضاء للجميع؟ + سيتم عرض رسائل من هؤلاء الأعضاء! + لا يمكن قراءة عبارة المرور في Keystore، يُرجى إدخالها يدويًا. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطورين. + سيتم إزالة الأعضاء من المجموعة - لا يمكن التراجع عن هذا! + المشرفين + لا يمكن قراءة عبارة المرور في Keystore. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطورين. + موافقة الانتظار + ضبّط مُشغلي الخادم + سياسة الخصوصية وشروط الاستخدام. + لا يمكن الوصول إلى الدردشات الخاصة والمجموعات وجهات اتصالك لمشغلي الخادم. + باستخدام SimpleX Chat، توافق على:\n- إرسال المحتوى القانوني فقط في المجموعات العامة.\n- احترام المستخدمين الآخرين – لا سبام. + اقبل + استخدم روابط قصيرة (تجريبي) + يتطلب هذا الرابط إصدار تطبيق أحدث. يُرجى ترقية التطبيق أو اطلب من جهة اتصالك إرسال رابط متوافق. + رابط كامل + رابط قصير + رابط قناة SimpleX + رابط اتصال غير مدعوم + استخدم منفذ TCP 443 للخوادم المُعدة مسبقًا فقط. + إيقاف التشغيل + الخوادم المُعدة مسبقًا + جميع الخوادم + 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 954c22abee..7af75e64e8 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 @@ -156,6 +158,8 @@ Error loading details Error adding member(s) Error joining group + Error accepting member + Error deleting chat with member Cannot receive file Sender cancelled file transfer. Unknown servers! @@ -168,6 +172,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 @@ -312,7 +318,12 @@ This message was deleted or not received yet. Report reason? Archive report? + Archive %d reports? + Archive all reports? The report will be archived for you. + All reports will be archived for you. + For me + For all moderators Error: %1$s @@ -341,6 +352,7 @@ Search Archive Archive report + Archive reports Delete report Sent message Received message @@ -412,6 +424,7 @@ send direct message you are invited to group join as %s + rejected connecting… Tap to start a new chat Chat with the developers @@ -447,12 +460,16 @@ Businesses Notes Reports + Report: %s All Add list 1 report %d reports Member reports - Archived member reports + %d messages + %d chats with members + 1 chat with a member + %d chat(s) Share message… @@ -475,9 +492,6 @@ Decoding error The image cannot be decoded. Please, try a different image or contact developers. The video cannot be decoded. Please, try a different video or contact developers. - you are observer - You can\'t send messages! - Please contact group admin. Files and media prohibited! Only group owners can enable files and media. Send direct message to connect @@ -496,6 +510,24 @@ Report violation: only group moderators will see it. Report content: only group moderators will see it. Report other: only group moderators will see it. + Report sent to moderators + You can view your reports in Chat with admins. + + You can\'t send messages! + contact not ready + contact deleted + not synchronized + contact disabled + you are observer + Please contact group admin. + request to join rejected + group is deleted + removed from group + you left + can\'t send messages + you are observer + reviewed by admins + member has old version Image @@ -540,6 +572,12 @@ Notifications + Disable automatic message deletion? + Change automatic message deletion? + Messages in this chat will never be deleted. + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Disable delete messages + Delete chat messages from your device. connect @@ -673,9 +711,11 @@ Mute + Mute all Unmute Favorite Unfavorite + Unread mentions Create list @@ -780,6 +820,8 @@ 1-time link SimpleX address Or show this code + Full link + Short link Share profile Select chat profile Error switching profile @@ -940,6 +982,13 @@ Message routing fallback Show message status To protect your IP address, private routing uses your SMP servers to deliver messages. + TCP port for messaging + Use web port + Use TCP port %1$s when no port is specified. + Use TCP port 443 for preset servers only. + All servers + Preset servers + Off Appearance Customize theme INTERFACE COLORS @@ -1009,6 +1058,7 @@ Address settings Business address Add your team members to the conversations. + Add short link Continue @@ -1024,6 +1074,7 @@ Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Edit image Delete image + Save admission settings? Save preferences? Save and notify contact Save and notify contacts @@ -1144,6 +1195,11 @@ Use random passphrase + Private chats, groups and your contacts are not accessible to server operators. + By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam. + Privacy policy and conditions of use. + Accept + Configure server operators Server operators Network operators SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. @@ -1396,7 +1452,9 @@ 1 day 1 week 1 month + 1 year %s second(s) + default (%s) Messages This setting applies to messages in your current chat profile Delete messages after @@ -1450,6 +1508,7 @@ Wrong database passphrase + Error reading database passphrase Encrypted database Database error Keychain error @@ -1471,7 +1530,9 @@ Please enter the previous password after restoring database backup. This action can not be undone. Restore Restore database error - Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers. + Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please contact developers. + Passphrase in Keystore can\'t be read. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers. + Passphrase in Keystore can\'t be read, please enter it manually. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers. Database upgrade Database downgrade Incompatible database version @@ -1533,6 +1594,8 @@ invited %1$s connected + accepted %1$s + accepted you left changed role of %s to %s blocked %s @@ -1544,6 +1607,7 @@ updated group profile invited via your group link connected directly + New member wants to join the group. you changed role of %s to %s you changed role for yourself to %s you blocked %s @@ -1551,6 +1615,8 @@ you removed %1$s you left group profile updated + you accepted this member + Please wait for group moderators to review your request to join the group. %s connected %s and %s connected @@ -1602,11 +1668,16 @@ owner + rejected removed left group deleted unknown status invited + pending approval + pending + pending review + review connecting (introduced) connecting (introduction invitation) connecting (accepted) @@ -1677,6 +1748,7 @@ Receipts are disabled This group has over %1$d members, delivery receipts are not sent. Invite + Chat with admins FOR CONSOLE @@ -1711,25 +1783,32 @@ Remove member? + Remove members? Remove member - + Chat with member Send direct message Member will be removed from group - this cannot be undone! + Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! + Members will be removed from chat - this cannot be undone! Remove Remove member Block member? Block member Block Block member for all? + Block members for all? Block for all All new messages from %s will be hidden! + All new messages from these members will be hidden! Unblock member? Unblock member Unblock Unblock member for all? + Unblock members for all? Unblock for all Messages from %s will be shown! + Messages from these members will be shown! Blocked by admin blocked disabled @@ -1822,7 +1901,6 @@ Website Conditions accepted on: %s. Conditions will be accepted on: %s. - Operator Use servers Use %s Current conditions text couldn\'t be loaded, you can review conditions via this link: @@ -1835,6 +1913,7 @@ View conditions Accept conditions Conditions of use + Updated conditions %s, accept conditions of use.]]> Use for messages To receive @@ -1983,6 +2062,7 @@ Contact preferences Group preferences Set group preferences + Set member admission Your preferences Disappearing messages Direct messages @@ -2052,6 +2132,8 @@ Prohibit sending SimpleX links Send up to 100 last messages to new members. Do not send history to new members. + Allow to report messsages to moderators. + Prohibit reporting messages to moderators. Members can send disappearing messages. Disappearing messages are prohibited. Members can send direct messages. @@ -2070,6 +2152,8 @@ SimpleX links are prohibited. Up to 100 last messages are sent to new members. History is not sent to new members. + Members can report messsages to moderators. + Reporting messages is prohibited in this group. Delete after %d sec %ds @@ -2091,10 +2175,34 @@ offered %s: %2s cancelled %s all members + moderators admins owners Enabled for + + Member admission + Review members + Review members before admitting ("knocking"). + off + all + + + Chats with members + No chats with members + Delete chat + Delete chat with member? + + + Chat with admins + Reject + Reject member? + Accept + Accept member + Member will join the group, accept member? + Accept as member + Accept as observer + What\'s new New in %s @@ -2253,12 +2361,24 @@ Delete or moderate up to 200 messages. Network decentralization The second preset operator in the app! - Enable flux + Enable Flux in Network & servers settings for better metadata privacy. for better metadata privacy. Improved chat navigation - Open chat on the first unread message.\n- Jump to quoted messages. Business chats Privacy for your customers. + Mention members 👋 + Get notified when mentioned. + Send private reports + Help admins moderating their groups. + Organize chats into lists + Don\'t miss important messages. + Better privacy and security + Private media file names. + Set message expiration in chats. + Better groups performance + Faster sending messages. + Faster deletion of groups. View updated conditions @@ -2480,7 +2600,7 @@ Messages sent Messages received Details - Starting from %s.\nAll data is kept private on your device.. + Starting from %s.\nAll data is kept private on your device. Message reception Active connections Pending @@ -2545,4 +2665,7 @@ Download errors Server address Open server settings + + + You can mention up to %1$s members per message! \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index c51b33e456..c37d4e6924 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -220,11 +220,11 @@ Грешка при свързване (AUTH) свързване… Промени адреса за получаване - Базата данни на чата е изтрита + Базата данни е изтрита Чатът работи Чатът е спрян - БАЗА ДАННИ НА ЧАТА - Базата данни на чат е импортирана + БАЗА ДАННИ + Базата данни е импортирана Потвърди новата парола… Потвърди актуализаациите на базата данни свързан @@ -338,7 +338,7 @@ Понижаване на версията на базата данни Актуализация на базата данни версията на базата данни е по-нова от приложението, но няма миграция надолу за: %s - групата изтрита + групата е изтрита Контактът е проверен създател Създай групов линк @@ -406,7 +406,7 @@ Идентификатори в базата данни и опция за изолация на транспорта. Изтрий адрес Изтрий адрес\? - ЦВЕТОВЕ НА ТЕМАТА + ЦВЕТОВЕ НА ИНТЕРФЕЙСА Създай Създай профил Изтрий изображение @@ -440,7 +440,7 @@ Изтрий сървър Не създавай адрес Име на профила: - двуслойно криптиране от край до край.]]> + Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения. Деактивирай потвърждениeто\? Активиране (запазване на промените) Активирай потвърждениeто\? @@ -491,7 +491,7 @@ Изтрий съобщенията Грешка при криптиране на базата данни Активиране на автоматично изтриване на съобщения\? - Вашата чат база данни не е криптирана - задайте парола, за да я защитите. + Вашата база данни не е криптирана - задайте парола, за да я защитите. Криптиране на база данни\? покана за група %1$s Изпратихте покана за групата @@ -567,13 +567,13 @@ Криптирана база данни Активирай TCP keep-alive Изчезващите съобщения са забранени в този чат. - Изчезващите съобщения са забранени в тази група. + Изчезващите съобщения са забранени. Изчезващи съобщения Въведи съобщение при посрещане…(незадължително) Съобщение при посрещане Грешка при свързване със сървъра С незадължително съобщение при посрещане. - Грешка при изтриване на чат базата данни + Грешка при изтриване на базата данни Грешка при промяна на настройката Грешка при създаване на групов линк Грешка при изтриване на групов линк @@ -597,10 +597,10 @@ Предстояща връзка със сървъра Вашият контакт трябва да бъде онлайн, за да осъществите връзката. \nМожете да откажете тази връзка и да премахнете контакта (и да опитате по -късно с нов линк). - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни Грешка: %s Грешка при запазване на файл - Грешка при импортиране на чат базата данни + Грешка при импортиране на базата данни Грешка при отстраняване на член Грешка при запазване на профила на групата Грешка при зареждане на SMP сървъри @@ -641,14 +641,14 @@ Файлът ще бъде получен, когато вашият контакт завърши качването му. Файлът ще бъде получен, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! Филтрирайте непрочетените и любимите чатове. - Членовете на групата могат да изпращат лични съобщения. + Членовете могат да изпращат лични съобщения. помощ ПОМОЩ Здравей, \nСвържи се с мен през SimpleX Chat: %s - Членовете на групата могат да добавят реакции към съобщенията. - Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа) - Членовете на групата могат да изпращат гласови съобщения. + Членовете могат да добавят реакции към съобщенията. + Членовете могат необратимо да изтриват изпратените съобщения. (24 часа) + Членовете могат да изпращат гласови съобщения. Дори когато е деактивиран в разговора. Бързо и без чакане, докато подателят е онлайн! Френски интерфейс @@ -668,8 +668,8 @@ Груповият профил се съхранява на устройствата на членовете, а не на сървърите. Скрий Забрани изпращането на изчезващи съобщения. - Файловете и медията са забранени в тази група. - Членовете на групата могат да изпращат файлове и медия. + Файловете и медията са забранени. + Членовете могат да изпращат файлове и медия. Скрити чат профили Допълнително намален разход на батерията Групово модериране @@ -693,7 +693,7 @@ Само вие можете да изпращате изчезващи съобщения. Само вашият контакт може да изпраща изчезващи съобщения. Забрани изпращането на изчезващи съобщения. - Членовете на групата могат да изпращат изчезващи съобщения. + Членовете могат да изпращат изчезващи съобщения. Невалиден QR код Невалиден линк! Неправилен код за сигурност! @@ -705,7 +705,7 @@ Покани приятели Скрий профила Как се използва форматирането - Може да се промени по-късно през настройките. + Как се отразява на батерията Незабавно Режим инкогнито Покани членове @@ -716,14 +716,14 @@ Покажи профила Как се използва курсив - Импортиране на чат база данни\? + Импортиране на база данни? Ако изберете да отхвърлите, подателят НЯМА да бъде уведомен. Изображение Изображението ще бъде получено, когато вашият контакт завърши качването му. Изображението е запазено в Галерия Изображението е изпратено Изображението ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! - Необратимото изтриване на съобщения е забранено в тази група. + Необратимото изтриване на съобщения е забранено. Подобрена конфигурация на сървъра Това може да се случи, когато: \n1. Времето за пазене на съобщенията е изтекло - в изпращащия клиент е 2 дена а на сървъра е 30. @@ -752,7 +752,7 @@ невалиден формат на съобщението Невалиден линк за връзка Скриване на контакт и съобщение - разрешете на SimpleX да работи във фонов режим в следващия диалогов прозорец. В противен случай известията ще бъдат деактивирани.]]> + Разрешете в следващия диалогов прозорец, за да получавате незабавно известия.]]> Незабавни известия Незабавни известия! Незабавните известия са деактивирани! @@ -765,7 +765,7 @@ Инсталирай SimpleX Chat за терминал Как работи Как работи SimpleX - Защитен от спам и злоупотреби + Защитен от спам Игнорирай Покани членове Необратимото изтриване на съобщения е забранено в този чат. @@ -791,7 +791,7 @@ Оценете приложението Уверете се, че адресите на WebRTC ICE сървъра са в правилен формат, разделени на редове и не са дублирани. Мрежа и сървъри - Мрежови настройки + Разширени настройки Порт порт %d Задължително @@ -852,7 +852,7 @@ Забрани реакциите на съобщенията. Моля, въведете предишната парола след възстановяване на резервното копие на базата данни. Това действие не може да бъде отменено. Голям файл! - Реакциите на съобщения са забранени в тази група. + Реакциите на съобщения са забранени. Ново в %s предлага %s предлага %s: %2s @@ -884,7 +884,7 @@ Получаващият адрес ще бъде променен към друг сървър. Промяната на адреса ще завърши, след като подателят е онлайн. Нов архив на база данни Стар архив на база данни - Някои не-фатални грешки са възникнали по време на импортиране - може да видите конзолата за повече подробности. + Някои не-фатални грешки са възникнали по време на импортиране: Съобщения Моля, въведете правилната текуща парола. Моля, съхранявайте паролата на сигурно място, НЯМА да можете да я промените, ако я загубите. @@ -955,7 +955,7 @@ Получаване на съобщения… Работи, когато приложението е отворено Simplex Chat услуга - SimpleX фонова услуга – използва няколко процента от батерията на ден.]]> + SimpleX работи във фонов режим вместо да използва push известия.]]> За да получавате известия, моля, въведете паролата на базата данни Влезте с вашите идентификационни данни Грешка при доставката на съобщението @@ -963,7 +963,7 @@ Най-вероятно този контакт е изтрил връзката с вас. Няма история Отвори конзолата - Отвори чат профилите + Промени чат профилите Моля, запомнете я или я съхранявайте на сигурно място - няма начин да възстановите загубена парола! Получено съобщение Отговори @@ -978,8 +978,8 @@ Разрешение е отказано! профилно изображение запазено място за профилно изображение - Протокол и код с отворен код – всеки може да оперира собствени сървъри. - Хората могат да се свържат с вас само чрез ликовете, които споделяте. + Всеки може да оперира сървъри. + Вие решавате кой може да се свърже с вас. Поверителността преосмислена Добави поверителна връзка Отвори @@ -1066,7 +1066,7 @@ Деактивиране на потвърждениe за доставка за групи\? Активиране за всички групи Изпращането на потвърждениe за доставка е разрешено за %d групи - Рестартирайте приложението, за да използвате импортирана чат база данни. + Рестартирайте приложението, за да използвате импортирана база данни. Тази група има над %1$d членове, потвърждениeто за доставка няма да се изпраща. СЪРВЪРИ %s: %s @@ -1173,14 +1173,14 @@ Напълно децентрализирана – видима е само за членовете. Транспортна изолация Благодарение на потребителите – допринесете през Weblate! - Хешът на предишното съобщение е различен. + Хешът на предишното съобщение е различен.\" Тествай сървърите Благодарение на потребителите – допринесете през Weblate! За да не се разкрива часовата зона, файловете с изображения/глас използват UTC. Този текст не е линк за връзка! Твърде много видеоклипове! Тази настройка се прилага за съобщения в текущия ви профил - За да се защити поверителността, вместо потребителски идентификатори, използвани от всички други платформи, SimpleX има идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. + За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. @@ -1189,8 +1189,8 @@ Докосни бутона Благодарим Ви, че инсталирахте SimpleX Chat! Запази и уведоми контактите - Ново поколение поверителни съобщения - Първата платформа без никакви потребителски идентификатори – поверителна по дизайн. + Бъдещето на комуникацията + Няма потребителски идентификатори. Системна Неправилно ID на следващото съобщение (по-малко или еднакво с предишното). \nТова може да се случи поради някаква грешка или когато връзката е компрометирана. @@ -1202,7 +1202,7 @@ Ще бъдете свързани, когато устройството на вашия контакт е онлайн, моля, изчакайте или проверете по-късно! Няма да загубите контактите си, ако по-късно изтриете адреса си. Вашите настройки - Вашият SimpleX адрес + Вашият адрес в SimpleX Използвай за нови връзки Вашите XFTP сървъри Използвай сървърите на SimpleX Chat\? @@ -1233,7 +1233,7 @@ Можете да споделите този адрес с вашите контакти, за да им позволите да се свържат с %s. Премахни от любимите ВИЕ - Вашата чат база данни + Вашата база данни Изчаква се получаването на изображението Изчаква се получаването на изображението Изчаква се получаването на видеото @@ -1245,7 +1245,7 @@ Гласово съобщение Гласово съобщение (%1$s) Гласово съобщение… - Гласовите съобщения са забранени в тази група. + Гласовите съобщения са забранени. непрочетено Добре дошли! Добре дошли %1$s! @@ -1275,8 +1275,7 @@ Вашият чат профил ще бъде изпратен на членовете на групата Вашият чат профил ще бъде изпратен \nдо вашия контакт - Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. -\nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. + Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. \nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. Актуализирането на настройките ще свърже отново клиента към всички сървъри. актуализиран профил на групата Видео @@ -1444,7 +1443,7 @@ Несъвместим! Изчаква се мобилното устройство да се свърже: Изтрий %d съобщения? - Свържи мобилно устройство + Свързване на мобилно устройство Свързване с %1$s? Премахни член Блокирай @@ -1630,7 +1629,7 @@ Потвърди мрежовите настройки Грешка при изтеглянето на архива Подготвя се качване - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни Стартиране на чата Квантово устойчиво криптиране Миграция на данните от приложението @@ -1711,8 +1710,8 @@ Мрежова връзка SimpleX линкове Забранете изпращането на SimpleX линкове - Членовете на групата могат да изпращат SimpleX линкове. - SimpleX линкове са забранени в тази група. + Членовете могат да изпращат SimpleX линкове. + SimpleX линкове са забранени. Активирано за собственици Камера @@ -1749,4 +1748,526 @@ Профилни изображения Променете формата на профилните изображения Квадрат, кръг или нещо между тях. - \ No newline at end of file + %1$d файл(а) не бяха изтеглени. + %1$d файл(а) все още се изтеглят. + Приети условия + Приеми условията + %1$d файлова грешка(и):\n%2$s + %1$d файл(а) бяха изтрити. + Неуспешно изтегляне на %1$d файл(а). + %1$s съобщения не са препратени + приета покана + %1$d друга(и) файлова(и) грешка(и). + a + b + За операторите + Допълнителен акцент 2 + Няма сървъри за получаване на файлове. + Грешка при поверително рутиране + Без Tor или VPN вашият IP адрес ще бъде видим за тези XFTP релета:\n%1$s. + Препращане на %1$s съобщението(ята)? + Препращане на съобщенията… + Препращане на %1$s съобщения + Позволи понижаване + Когато IP-то е скрито + НЕ изпращайте съобщения директно, дори ако вашият или получаващият сървър не поддържат поверително рутиране. + За поверителното рутиране + Грешка при свързване към препращащият сървър %1$s. Моля, опитайте по-късно. + Покажи състоянието на съобщението + За да защити вашия IP адрес, поверително рутиране използва вашите SMP сървъри за доставяне на съобщения. + Препращане на съобщенията без файловете? + Неизвестни сървъри + ФАЙЛОВЕ + Показване на списъка на чатовете в нов прозорец + Системна + Тъмна + Черна + Нулиране на цветовете + То защитава вашия IP адрес и връзки. + Адресът на препращащия сървър е несъвместим с мрежовите настройки: %1$s. + Версията на препращащия сървър е несъвместима с мрежовите настройки: %1$s. + Препращащият сървър %1$s не успя да се свърже с получаващия сървър %2$s. Моля, опитайте по-късно. + Версията на получаващия сървър %1$s е несъвместима с препращащия сървър %2$s. + Грешка от получаващия сървър: %1$s + Адресът на сървъра е несъвместим с мрежовите настройки. + Съобщението е препратено + Все още няма директна връзка, съобщението е препратено от администратора. + Тема на приложението + Защитете вашия IP адрес от реле сървърите за съобщения, избрани от вашите контакти.\nАктивирайте в настройките *Мрежа и сървъри*. + Поверително рутиране на съобщенията 🚀 + Препращайте до 20 съобщения наведнъж. + Не сте свързани с тези сървъри. Поверителното рутиране се използва за доставяне на съобщенията до тях. + Грешен ключ или неизвестна връзка - най-вероятно тази връзка е изтрита. + Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения. + Препращащ сървър: %1$s\nГрешка: %2$s + Версията на сървъра е несъвместима с мрежовите настройки. + Защити IP адреса + ПОВЕРИТЕЛНО РУТИРАНЕ НА СЪОБЩЕНИЯ + Приложението ще поиска потвърждение за изтегляния от неизвестни файлови сървъри (с изключение на .onion сървъри или когато SOCKS прокси е активирано). + Грешка: %1$s + Изтегляне + Съобщенията бяха изтрити, след като ги избрахте. + Нищо за препращане! + Поверително рутиране + Незащитен + Изпращайте съобщения директно, когато IP адресът е защитен и вашият или получаващият сървър не поддържа поверително рутиране. + Изпращайте съобщения директно, когато вашият или получаващият сървър не поддържат поверително рутиране. + Винаги използвай поверително рутиране. + Използвай поверително рутиране с неизвестни сървъри. + Тема на профила + Цветен режим + Цветове за тъмен режим + Няма сървъри за поверително рутиране на съобщения. + Без Tor или VPN вашият IP адрес ще бъде видим за файловите сървъри. + Предупреждение за доставката на съобщението + НЕ използвайте поверително рутиране. + Цветове на чата + Грешка при препращане на съобщенията + Адресът на получаващия сървър %1$s е несъвместим с настройките на препращащия сървър %2$s. + Неизвестни сървъри! + Проблеми с мрежата - съобщението е отказано след много опити за изпращане. + Препращащ сървър: %1$s\nГрешка от получаващия сървър: %2$s + Може да копирате и намалите размера на съобщението, за да бъде изпратено. + Винаги + Не + Никога + Режим за рутиране на съобщения + Използвай поверително рутиране с неизвестни сървъри, когато IP адресът не е защитен. + Светла + Да + Тема на чата + Получен отговор + Изпратен отговор + Премахни изображението + Тапетен фон + Тапетен акцент + Добър ден! + Добро утро! + Запълване + Повтори + Мащаб + Моля, опитайте по-късно. + Файлът не е намерен - най-вероятно файлът е бил изтрит или отказан. + Грешка на файловия сървър: %1$s + Грешен ключ или неизвестен адрес на файлово парче - най-вероятно файлът е изтрит. + Файлов статус: %s + Всички профили + Започвайки от %s.\nВсички данни се съхраняват поверително на вашето устройство.. + Свържете отново сървъра, за да принудите доставката на съобщенията. Това използва допълнителен трафик. + Грешка при нулиране на статистиката + Сканирай / Постави линк + Конфигурирани XFTP сървъри + Размер на шрифта + Статус на съобщението: %s + Задай тема по подразбиране + Потвърдете файловете от неизвестни сървъри. + Изпратени съобщения + Грешка при стартиране на WebView. Актуализирайте системата си до новата версия. Моля, свържете се с разработчиците.\nГрешка: %s + Други XFTP сървъри + Мащабиране + Всички цветови режими + Светъл режим + С намален разход на батерията. + Файлова грешка + Статус на съобщението + Файлов статус + Нови чат теми + Безопасно получаване на файлове + Персийски потребителски интерфейс + Няма информация, опитайте да презаредите + Показване на информация за + Информация за сървърите + Файлове + Транспортни сесии + Грешка + Информация за опашката за съобщения + няма + информация за опашката на сървъра: %1$s\n\nпоследно получено съобщение: %2$s + Подробности + Повторно свързване на сървърите? + Предишни свързани сървъри + Грешка при повторното свързване на сървърите + Грешка при повторното свързване на сървъра + Повторно свързване на всички сървъри + Нулиране + Свалено + Избраните чат настройки забраняват това съобщение. + неактивен + Тъмен режим + Активни връзки + Предстоящи + Свързани сървъри + Общо + деактивирано + Свържете се отново с всички свързани сървъри, за да принудите доставката на съобщенията. Това използва допълнителен трафик. + Този линк е използван с друго мобилно устройство, моля, създайте нова линк на настолното устройство. + Свързан + Получени съобщения + Покритие на съобщенията + Сървъри през прокси + Нулиране на всички статистически данни + Нулиране на всички статистически данни? + Статистиката на сървърите ще бъде нулирана - това не може да бъде отменено! + Подробна статистика + Членът е неактивен + Съобщението може да бъде доставено по-късно, ако членът стане активен. + Не може да се изпрати съобщението + Покажи процент + Повторно свързване на сървъра? + Качено + Изпратени общо + Грешка при копиране + Адресът на сървъра е несъвместим с мрежовите настройки: %1$s. + Версията на сървъра е несъвместима с вашето приложение: %1$s. + Грешки + Статистика + Свързване + Изпратени съобщения + Отстраняване на грешки за доставка + Изпълване + Разширени настройки + Приложи към + Нулиране с темата за приложението + Нулиране с потребителска тема + Направете вашите чатове да изглеждат различно! + Текущ профил + Повторно свързване + Получени съобщения + Получени общо + Получени грешки + Моля, проверете дали мобилното и настолното устройство са свързани към една и съща локална мрежа и дали защитната стена на настолното устройство позволява връзката.\nМоля, споделете всички други проблеми с разработчиците. + Временна файлова грешка + Конфигурирани SMP сървъри + Други SMP сървъри + Подобрена доставка на съобщения + опити + изтекли + други + дубликати + Потвърден + Грешки при потвърждението + Връзки + Започвайки от %s. + XFTP сървър + Изпратени директно + Изпратени чрез прокси + Чрез прокси + SMP сървър + Изпрати грешки + други грешки + грешки при декриптиране + Грешки при изтриване + Създаден + Завършен + Изтрит + Защитен + Абониран + Абонаменти игнорирани + Абонаментни грешки + Качени парчета + Качени файлове + Грешки при качване + Размер + Парчета изтрити + Изтеглени парчета + Изтеглени файлове + Настройки + Деактивирано + Стабилен канал + Бета канал + Налична актуализация: %s + Изтегляне на актуализация на приложението, не затваряйте приложението + Създай + Много + Форма на съобщение + Позволи обаждания? + Обажданията са забранени! + Моля, поискайте вашия контакт да позволи обажданията. + Размазване за по-добра поверителност. + Изтрийте до 20 съобщения наведнъж. + Възпроизвеждане от чат списъка. + SimpleX протоколите, прегледани от Trail of Bits. + По-добра сигурност ✅ + %1$s съобщения се запазват + Запази разговора + Използвай различни прокси идентификационни данни за всяка връзка. + Грешка за запазване на прокси + Актуализацията на приложението е изтеглена + Обаждането на контакта не е позволено + Тече свързване с контакт, моля изчакайте или проверете по-късно! + Обаждането на груповия член е не е позволено + Системен режим + Архивирайте контактите, за да разговаряте по-късно. + Покани + Някои файлове не са били експортирани + Базата данни е експортирана + Невалиден линк + Моля, проверете, че SimpleX линкът е правилен. + Изтрий само разговора + SOCKS прокси + Автоматични актуализации на приложението + Запази и се свържи отново + Достъпен панел + Използвайте приложението с една ръка. + Свържете се с приятелите си по-бързо. + Контролирайте вашата мрежа + Изтеглете новите версии от GitHub. + Увеличете размера на шрифта. + По-добри обаждания + Превключете аудио и видео по време на разговора. + Няма филтрирани контакти + Вашите контакти + Можете да запазите експортирания архив. + Можете да мигрирате експортираната база данни. + Можете да го промените в настройките за Изглед. + TCP връзка + Състояние на връзката и сървърите. + Изтриване на %d съобщения на членовете? + Потвърди изтриването на контакта? + Потребителско име + Провери за актуализации + Изключено + Средно + Малко + Ъгъл + Опашка + Контактът е изтрит.. + Трябва да разрешите на вашия контакт да може да ви се обажда, за да можете и вие да се обаждате. + Постави линк + Връзката ви беше преместена към %s, но възникна неочаквана грешка при пренасочването ви към профила. + Сесия на приложението + Сървър + Изтегли %s (%s) + Пропусни тази версия + Провери за актуализации + БАЗА ДАННИ + Превключване на чат списъка: + Можете да изпращате съобщения до %1$s от архивираните контакти. + Достъпен панел + Изпращането на съобщения на груповия член не е налично + Грешка при стартиране на WebView. Уверете се, че сте инсталирали WebView и поддържаната архитектура е ARM64.\nГрешка: %s + Съобщенията ще бъдат изтрити - това не може да бъде отменено! + Съобщенията ще бъдат изтрити за всички членове. + Съобщенията ще бъдат маркирани като модерирани за всички членове. + Избрано %d + Нищо не е избрано + Съобщение + отвори + съобщение + обаждане + Контактът ще бъде изтрит - това не може да бъде отменено! + свързване + Изтрий без известие + Разговорът е изтрит! + Все още ще можете да видите разговора с %1$s в списъка с чатовете. + Сподели профил + Избери чат профил + Грешка при превключване на профил + Покани + Звукът е заглушен + Панели на приложението + Изпратете съобщение за да се активират обажданията. + търсене + видео + Контактът е изтрит! + Архивирани контакти + Достъпни панели + Отвори настройките на сървъра + Продължи + Отвори местоположението на файла + Инсталиране на актуализация + Напомни по-късно + Инсталирането е успешно + Моля, рестартирайте приложението. + Изтеглянето на актуализацията е отменено + Премахни архив? + Каченият архив на базата данни ще бъде окончателно премахнат от сървърите. + За да извършвате обаждания, разрешете да използва микрофона. Прекратете разговора и опитайте да се обадите отново. + Натиснете бутона за информация близо до адресното поле, за да разрешите използването на микрофона. + Отворете Safari Настройки / Уеб страници / Микрофон, след което изберете Позволи за localhost. + Нов интерфейс 🎉 + Нови медийни опции + Подобрен интерфейс + Избери + Съобщенията ще бъдат маркирани за изтриване. Получателят(ите) ще могат да разкрият тези съобщения. + Размазване на медия + Сървъри за съобщения + Ново съобщение + Сървъри за медия и файлове + Прокси идентификация + Използвай случайни идентификационни данни + Използвай различни прокси идентификационни данни за всеки профил. + Не използвай идентификационни данни с прокси. + Вашите идентификационни данни могат да бъдат изпратени некриптирани. + Парола + Уверете се, че прокси конфигурацията е правилна. + Нови идентификационни данни за SOCKS ще се използват всеки път, когато стартирате приложението. + Ще се използват нови SOCKS идентификационни данни за всеки сървър. + За да бъдете уведомени за новите версии, включете периодичната проверка за стабилни или бета версии. + Деактивиране + Нулирай всички съвети + Грешки при изтегляне + Сървърен адрес + Няма сървъри за съобщения. + Няма сървъри за получаване на съобщения. + За чат профил %s: + Грешки в конфигурацията на сървърите. + Недоставени съобщения + Връзката достигна лимита за недоставени съобщения, вашият контакт може да е офлайн. + Сподели SimpleX адресът в социалните мрежи. + Сигурност на връзката + За да се предпазите от подмяна на вашия линк, можете да сравните кодовете за сигурност на контакта. + За социалните мрежи + SimpleX адрес или еднократен линк? + Създай еднократен линк + Приложението защитава вашата поверителност, като използва различни оператори за всеки разговор. + Например, ако контактът ви получава съобщения чрез SimpleX Chat сървър, приложението ви ще ги достави чрез Flux сървър. + Сървърни оператори + Мрежови оператори + Преглед по-късно + Можете да конфигурирате операторите в Мрежа и сървъри. + Условията ще бъдат автоматично приети за активираните оператори на: %s. + Вашите сървъри + Условията ще бъдат приети на: %s. + Използвай сървърите + Използвай %s + %s.]]> + Преглед на условията + Условия за ползване + %s, приемете условията за използване.]]> + За изпращане + Добавени сървъри за съобщения + Използвай за файлове + - Отворете чата при първото непрочетено съобщение.\n- Преминете към цитирани съобщения. + Резервно рутиране на съобщения + Активирай логовете + Добавете членовете на вашия екип към разговорите. + задраскан + Продължи + Добави приятели + %s.]]> + Уеб страница + Условия, приети на: %s. + Дистанционни мобилни устройства + Предварително зададени сървъри + %s.]]> + Отвори промените + Грешка при актуализиране на сървъра + Сървърният протокол е променен. + Грешка при добавяне на сървър + Личните съобщения между членовете са забранени в този чат. + Персонализирана форма на съобщенията. + По-добри дати на съобщението. + Активиране на flux + за по-добра поверителност на метаданните. + Подобрена навигация в чата + Грешка при запазване на базата данни + Вашият профил ще бъде изпратен до членовете + %s.]]> + За получаване + Добавени медийни и файлови сървъри + %s.]]> + Връзката не е готова. + Оператор + Вижте актуализираните условия + Xiaomi устройства : моля, активирайте Autostart в системните настройки, за да работят известията.]]> + Няма съобщение + Или да се сподели лично + криптирани от край до край, с постквантова сигурност в директните съобщения.]]> + Без фонова услуга + Проверявай за съобщения на всеки 10 минути + Приложението винаги работи във фонов режим + Известия и батерия + Условията ще бъдат приети за активираните оператори след 30 дни. + Можете да конфигурирате сървърите през настройките. + Актуализация + %s.]]> + Използвай за съобщения + Личните съобщения между членовете са забранени. + %1$s.]]> + SimpleX адресът и еднократните линкове за връзки са безопасни за споделяне чрез всеки месинджър. + Можете да зададете име на връзка, за да запомните с кого е споделена. + Сървърът е добавен към оператор %s. + Грешка при актуализирането на чат списъка + Грешка при създаването на чат списъка + Това съобщение е изтрито или не е получено. + Грешка при зареждането на чат списъка + Няма чатове в списъка %s. + Няма непрочетени чатове + Докосни Създаване на SimpleX адрес в менюто, за да го създадете по-късно. + Няма чатове + Няма намерени чатове + Любими + Контакти + Групи + Добави списък + Отвори с %s + Поправи + В ход е предоговаряне на криптирането. + Създай списък + Добави към списъка + Запази списъка + Името на списъка и емотиконите трябва да са различни за всички списъци. + Всички чатове ще бъдат премахнати от списъка %s и списъкът ще бъде изтрит + Редактирай + само с един контакт - споделете лично или чрез произволен месинджър.]]> + Адрес или еднократен линк? + Нов сървър + %s сървъри + Текстът на текущите условия не може да бъде зареден, можете да прегледате условията през този линк: + Сървърният оператор е променен. + Покана за чат + Чатът ще бъде изтрит за вас - това не може да бъде отменено! + Напусни чата + Изтрий чата + Изтрий чата? + Чатът ще бъде изтрит за всички членове - това не може да бъде отменено! + Членът ще бъде премахнат от чата - това не може да бъде отменено! + Чат + Преглед на условията + Сървърите за нови файлове от текущия ви профил + Поверителност за вашите клиенти. + Децентрализация на мрежата + Вторият предварително зададен оператор в приложението! + Бизнес чатове + Сподели адресът публично + Всички + Само собствениците на чат могат да променят настройките. + Изтрий списъка? + Списък + Изтрий + Бизнеси + Име на списъка... + Когато е активиран повече от един оператор, никои от тях няма метаданни, за да научи кой с кого комуникира. + Чатът вече съществува! + Ролята ще бъде променена на %s. Всички в чата ще бъдат уведомени. + Мрежов оператор + Сървър на оператора + Прозрачност + Изтриване или модериране до 200 съобщения. + Или импортирайте архивен файл + заявка за свързване + Грешка при запазване на сървърите + Няма сървъри за медия и файлове. + Няма сървъри за изпращане на файлове. + Грешка при приемане на условия + Съобщението е твърде голямо! + Моля, намалете размера на съобщението и го изпратете отново. + Моля, намалете размера на съобщението или премахнете мултимедията и изпратете отново. + Бизнес адрес + Напусни чата? + Размазване + SimpleX Chat и Flux сключиха споразумение за включване на управлявани от Flux сървъри в приложението. + %s.]]> + %s.]]> + Отвори условията + Превключете чат профила за еднократен линк за връзка. + Поправи връзката? + Връзката изисква предоговаряне на криптирането. + Сподели еднократен линк за връзка с приятел + Настройки на адреса + Изберете мрежови оператори за използване. + Как помага за поверителността + Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. + Добави членове на екипа + Заглушаване на всички + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml new file mode 100644 index 0000000000..0e179fadd4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -0,0 +1,2356 @@ + + + Afegiu servidors escanejant codis QR. + %1$s missatges no reenviats + Tots els missatges nous de %s s\'amagaran! + L\'aplicació només pot rebre notificacions quan s\'està executant, no s\'iniciarà cap servei en segon pla + El servei en segon pla sempre s\'executa: les notificacions es mostraran tan aviat com els missatges estiguin disponibles. + Color addicional + S\'ha demanat rebre la imatge + blocat + Tots els vostres contactes, converses i fitxers es xifraran de manera segura i es penjaran en trossos als relés XFTP configurats. + Sempre actiu + Identificador incorrecte de missatge + per a cada contacte i membre del grup.\nTingueu en compte: si teniu moltes connexions, el vostre el consum de bateria i trànsit pot ser substancialment més elevat i algunes connexions poden fallar.]]> + Hash incorrecte de missatge + L\'optimització de la bateria està activa, desactiva el servei en segon pla i les sol·licituds periòdiques de missatges nous. Podeu tornar-los a activar mitjançant la configuració. + intents + Blocar + %s té una versió no compatible. Si us plau, assegureu-vos que feu servir la mateixa versió als dos dispositius]]> + Trucada + L\'autenticació ha fallat + Autenticació no disponible + Tant tu com el teu contacte podeu enviar missatges de veu. + trucar + La trucada ja s\'ha acabat! + Es pot desactivar mitjançant la configuració: les notificacions encara es mostraran mentre l\'aplicació s\'executa.]]> + Color + El canvi d\'adreça s\'avortarà. S\'utilitzarà l\'adreça de recepció antiga. + Configuració avançada + Accepta automàticament les sol·licituds de contacte + Vols permetre els missatges de veu? + Mala adreça d\'ordinador + xifratge d\'extrem a extrem amb PFS, repudi i recuperació per intrusió.]]> + Tots els teus contactes romandran connectats. L\'actualització del perfil s\'enviarà als teus contactes. + Ja estem connectant! + Ja us uniu al grup! + Adjuntar + Identificador incorrecte de missatge + Cancel·la la vista prèvia del fitxer + S\'ha superat la capacitat: el destinatari no ha rebut missatges enviats anteriorment. + per a cada perfil de xat que tingueu a l\'aplicació.]]> + Afegeix un contacte: crea un enllaç d\'invitació nou o connecta\'t mitjançant un enllaç que has rebut.]]> + Tingueu en compte: utilitzar la mateixa base de dades en dos dispositius trencarà el desxifrat dels missatges de les vostres connexions, com a protecció de seguretat.]]> + Utilitza més bateria! L\'aplicació sempre s\'executa en segon pla: les notificacions es mostren a l\'instant.]]> + %s està en mal estat]]> + %s amb el motiu: %s]]> + %1$d missatge(s) moderat(s) per %2$s + blocat per l\'administrador + xifratge extrem a extrem resistent quàntic amb PFS, repudi i recuperació d\'intrusió.]]> + %1$d altre(s) error(s) del fitxer. + No es pot rebre el fitxer + Permet-ho al següent diàleg per rebre notificacions a l\'instant.]]> + No es pot inicialitzar la base de dades + Dispositius Xiaomi: activeu l\'inici automàtic a la configuració del sistema perquè les notificacions funcionin.]]> + Cancelar vista prèvia de la imatge + No es pot enviar el missatge + S\'ha demanat rebre el vídeo + Tornar + Càmera + Càmera no disponible + Cancelar + Cancel·la el missatge en directe + a dalt, aleshores: + Crea grup: per crear un grup nou.]]> + Escaneja el codi QR.]]> + Obre a l\'aplicació per a mòbils i després toca Connecta a l\'aplicació.]]> + Tots els missatges se suprimiran; això no es pot desfer! + Tots els missatges s\'eliminaran NOMÉS per a tu; això no es pot desfer! + cancel·la la vista prèvia de l\'enllaç + Es compartirà un nou perfil aleatori. + només amb un contacte: compartiu-lo en persona o mitjançant qualsevol missatger.]]> + escanejar el codi QR a la videotrucada o el vostre contacte pot compartir un enllaç d\'invitació.]]> + mostra el codi QR a la videotrucada o comparteix l\'enllaç.]]> + Contactes arxivats + Guia de l\'usuari.]]> + Configuració de xarxa avançada + Configuració avançada + Accedir als servidors mitjançant el servidor SOCKS al port %d? S\'ha d\'iniciar abans d\'activar aquesta opció. + per sessió + Tingueu en compte: els retransmissions de missatges i fitxers es connecten mitjançant un servidor intermediari SOCKS. Les trucades i l\'enviament de visualitzacions prèvies d\'enllaç utilitzen connexió directa.]]> + Permetre la versió anterior + Sempre + Utilitzeu sempre l\'encaminament privat. + Creació de l\'aplicació: %s + Aparença + S\'ha baixat l\'actualització de l\'aplicació + Versió + Versió de l\'aplicació: v%s + Beta + Tots els teus contactes romandran connectats. + Afegeix els membres del teu equip a les converses. + Acceptació automàtica + Adreça d\'empresa + negreta + trucada acabada %1$s + error de trucada + trucant… + trucada en curs + Càmera + Càmera i micròfon + Qualsevol pot allotjar servidors. + Bluetooth + xifrats d\'extrem a extrem, amb seguretat postquàntica als missatges directes.]]> + El millor per a la bateria. Només rebràs notificacions quan l\'aplicació s\'està executant (Sense servei en segon pla).]]> + Bo per a la bateria. L\'aplicació revisa els missatges cada 10 minuts. Podeu perdre trucades o missatges urgents.]]> + repositori GitHub.]]> + %1$s vol connectar amb tu mitjançant + Feu servir sempre el retransmisor + App always runs in background + trucada + trucada (no xifrada) + Trucades d\'àudio i vídeo + Trucades a la pantalla de bloqueig: + %1$d missatge(s) omés(os). + %1$d missatge(s) omés(os). + Respondre trucada + Àudio desactivat + Àudio activat + hash de missatge erroni + La trucada ha finalitzat + Trucada en curs + Còpia de seguretat de dades de l\'aplicació + Contrasenya de l\'aplicació + S\'ha cancel·lat l\'autenticació + Accepta imatges automàticament + S\'eliminaran totes les dades de l\'aplicació. + Es crea un perfil de xat buit amb el nom proporcionat i l\'aplicació s\'obre com de costum. + La contrasenya de l\'aplicació es substitueix per una contrasenya d\'autodestrucció. + APLICACIÓ + ICONA APLICACIÓ + Desenfocar els mitjans + TRUCADES + Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. + Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions. + No es pot accedir a Keystore per desar la contrasenya de la base de dades + Tingueu en compte: NO podreu recuperar ni canviar la contrasenya si la perdeu.]]> + No es poden convidar contactes! + ha blocat a %s + i %d esdeveniments més + acordant el xifratge… + acordant el xifratge per a %s… + autor + %1$s MEMBRES + No es pot convidar el contacte! + Tots els membres del grup romandran connectats. + Blocar per a tots + Blocar membre + Blocar membre? + Blocar membre per a tots? + Blocat per l\'administrador + No es pot trucar al contacte + Permetre trucades? + Trucades prohibides! + No es pot trucar al membre del grup + No es pot enviar missatges als membres del grup + %s.]]> + %s.]]> + %s.]]> + %s.]]> + S\'han afegit servidors multimèdia i de fitxers + S\'han afegit servidors de missatges + Tots els xats i missatges se suprimiran; això no es pot desfer! + Negre + Tema de l\'aplicació + Secundari addicional + Fons + Color addicional 2 + Tots els modes + sempre + Aplicar a + Barres d\'eines d\'aplicació + Desenfocar + Trucades d\'àudio/vídeo + \nDisponible en v5.1 + Permet trucades només si el vostre contacte ho permet. + Permet la desaparició de missatges només si el vostre contacte ho permet. + "Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores)" + Permet les reaccions als missatges només si el teu contacte les permet. + Permet missatges de veu només si el vostre contacte els permet. + Permet que els teus contactes afegeixin reaccions als missatges. + Permet que els teus contactes et truquin. + Permet als teus contactes eliminar de manera irreversible els missatges enviats. (24 hores) + Permet que els teus contactes enviïn missatges que desapareixen. + Permet que els teus contactes enviïn missatges de veu. + Tant vos com els vostres contactes podeu suprimir de manera irreversible els missatges enviats. (24 hores) + Tant tu com el teu contacte podeu enviar missatges que desapareguin. + Tant tu com el teu contacte podeu afegir reaccions als missatges. + Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores) + Permet enviar missatges directes als membres. + Permeteu suprimir de manera irreversible els missatges enviats. (24 hores) + Permet enviar missatges que desapareixen. + Permet enviar fitxers i mitjans. + Permet enviar missatges de veu. + Les trucades d\'àudio/vídeo estan prohibides. + Tant tu com el teu contacte podeu fer trucades. + Permet enviar enllaços SimpleX. + tots els membres + cancel·lat %s + Els administradors poden crear els enllaços per unir-se als grups. + Trucades d\'àudio i vídeo + Per perfil de xat (per defecte) o per connexió (BETA). + Totes les dades s\'esborren quan s\'introdueix. + Contrasenya de l\'aplicació + Missatges millorats + Unes quantes coses més + Xifratge dels fitxers locals nous (excepte vídeos). + Àrab, búlgar, finès, hebreu, tailandès i ucraïnès - gràcies als usuaris i Weblate. + Grups millorats + Blocar membres del grup + Migració de dades d\'aplicacions + Arxiva els contactes per xatejar més tard. + Desenfoca per a una millor privadesa. + Trucades millorades + Dates de missatges millorades. + Seguretat millorada ✅ + Millor experiència d\'usuari + Xats d\'empresa + (aquest dispositiu v%s)]]> + (nou)]]> + %s s\'ha desconnectat]]> + Usar des de l\'ordinador a l\'aplicació mòbil i escaneja el codi QR.]]> + %s està ocupat]]> + %s està inactiu]]> + %s no es troba]]> + %s s\'ha desconnectat]]> + Aplicar + Arxivar i carregar + Arxivant bases de dades + Cancel·la la migració + Migra des d\'un altre dispositiu al dispositiu nou i escaneja el codi QR.]]> + Tots els perfils + Avís: l\'arxiu s\'eliminarà.]]> + Reconegut + Errors de reconeixement + %1$d fitxer(s) no s\'ha(n) baixat. + %1$d missatge(s) no s\'ha(n) pogut desencriptar. + s\'està connectant + blocat + k + Connecta + error + vostè + EN DIRECTE + moderat + reenviat + desat + s\'està connectant… + Descripció + %1$d error(s) de fitxer:\n%2$s + Desconnecta + Permet + Autentica + Desbloca + Comparteix + Desa + Edita + Info + Cerca + Reenviat + Esborra + Mostra + Amaga + Modera + Selecciona + Expandeix + La baixada ha fallat per a %1$d fitxer(s). + %1$d fitxer(s) ha(n) estat eliminat(s). + esborrat + Ocult + Còpia + Lliurament + %1$d encara s\'estan baixant els fitxers. + Connecta + Error + Respon + Amaga + connectat + Atura + Connectat + Immediatament + Historial + Desat + Permet + Revoca + invitació acceptada + 30 segons + Accepta + Voleu acceptar la sol·licitud de connexió? + Acceptar incògnit + Quant a l\'adreça de SimpleX + Enllaç d\'un sol ús + Afegiu un contacte + Adreça o enllaç d\'un sol ús? + Afegiu servidors predefinits + Afegeix servidor + Quant a SimpleX + trucada acceptada + Accepta + 1 dia + 1 mes + 1 setmana + administrador + Afegiu-hi amics + Afegeix membres de l\'equip + Afegeix missatge de benvinguda + Adreça + Cancel·la el canvi d\'adreça + Accepta les condicions + Condicions acceptades + Afegiu un perfil + Voleu cancel·lar el canvi d\'adreça? + 1 minut + Accepta + 6 noves llengües d\'interfície + Cancel·la + Quant a SimpleX Chat + Els administradors poden blocar un membre per a tothom. + Connexions actives + 5 minuts + Accepta + Quant als operadors + Afegiu l\'adreça al vostre perfil per tal que els vostres contactes puguin compartir-la amb la resta del món. L\'actualització del perfil s\'enviarà als vostres contactes. + Configuració d\'adreça + Afegeix a un altre dispositiu + administradors + Voleu suprimir el contacte? + Desapareixerà a: %s + L\'autenticació del dispositiu està desactivada. S\'està desactivant el bloqueig SimpleX. + L\'autenticació del dispositiu no està activada. Podeu activar SimpleX Lock mitjançant Configuració, un cop hàgiu activat l\'autenticació del dispositiu. + desactivat + %1$s.]]> + %1$s.]]> + L\'adreça del servidor de destinació de %1$s és incompatible amb la configuració del servidor de reenviament %2$s. + Suprimeix el perfil + %d hores + Esborrar per mi + Els missatges directes entre membres estan prohibits en aquest xat. + La versió del servidor de destinació de %1$s és incompatible amb el servidor de reenviament %2$s. + La migració de la base de dades està en curs. Pot trigar uns minuts. + %d missatge(s) bloquejat(s) + %d missatge(s) bloquejat(s) per l\'administrador + %d missatge(s) marcat(s) eliminat(s) + Error de desxifrat + Suprimeix la cua + Esborrar fitxer + SimpleX s\'executa en segon pla en lloc d\'utilitzar notificacions push.]]> + Desactivar notificacions + Ús de la bateria de l\'aplicació / Sense restriccions a la configuració de l\'aplicació.]]> + Ús de la bateria de l\'aplicació / Sense restriccions a la configuració de l\'aplicació.]]> + %d minuts + Desactiva el bloqueig SimpleX + Error del servidor de destinació: %1$s + Voleu suprimir el missatge? + Voleu suprimir els %d missatges? + Voleu suprimir %d missatges dels membres? + Vols suprimir el missatge del membre? + Error de descodificació + Eliminar i notificar el contacte + Suprimeix el contacte + Suprimeix sense notificació + Desconnectat + Missatge que desapareix + Suprimeix + Suprimeix + Voleu suprimir la connexió pendent? + Contrasenya i exportació de la base de dades + Suprimeix el servidor + Utilitza els hosts .onion a No si el servidor SOCKS no els admet.]]> + Desactivat + NO envieu missatges directament, encara que el vostre servidor de destinació no admeti l\'encaminament privat. + Voleu suprimir l\'adreça? + Opcions de desenvolupador + Desactivar + Esborrar adreça + Suprimeix la imatge + El nom mostrat no pot contenir espais en blanc. + Descentralitzada + Desactivar + Desactivar per a tothom + Desactiva per a tots els grups + Desactivar (conservant anul·lacions) + Desactivar (mantenint les anul·lacions del grup) + Desactivar rebuts? + Desactivar rebuts per a grups? + Eines per a desenvolupadors + DISPOSITIU + La base de dades es xifra amb una contrasenya aleatòria. Si us plau, canvieu-la abans d\'exportar. + Contrasenya de la base de dades + Voleu suprimir el perfil? + Esborrar la base de dades + Suprimeix tots els fitxers + Voleu suprimir els fitxers i els mitjans? + Suprimeix els fitxers de tots els perfils + %d fitxer(s) amb una mida total de %s + Base de dades xifrada! + Suprimir missatges + La base de dades es xifra amb una contrasenya aleatòria, podeu canviar-la. + La contrasenya de xifratge de la base de dades s\'actualitzarà. + La contrasenya de xifratge de la base de dades s\'actualitzarà i s\'emmagatzemarà a la configuració. + La contrasenya de la base de dades és diferent de la desada al Keystore. + La contrasenya de la base de dades és necessària per obrir el xat. + La base de dades es xifrarà i la contrasenya s\'emmagatzemarà a la configuració. + La base de dades es xifrarà. + La base de dades es xifrarà i la frase de contrasenya s\'emmagatzemarà a Keystore. + La contrasenya de xifratge de la base de dades s\'actualitzarà i s\'emmagatzemarà a Keystore. + Actualització de la base de dades + La versió de la base de dades és més recent que l\'aplicació, però no hi ha cap migració cap avall per a: %s + migració diferent a l\'aplicació/base de dades: %s / %s + contacte eliminat + grup esborrat + %d esdeveniments de grup + %d contacte(s) seleccionat(s) + Suprimir xat + Suprimir xat? + Suprimir grup + Suprimir grup? + Suprimir enllaç + Suprimir enllaç? + Entrega de depuració + Suprimit + Suprimit: %s + Desapareixerà a + directe + inactiu + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s, accepteu les condicions d\'ús.]]> + Voleu suprimir el perfil? + Suprimeix el perfil + predeterminat (%s) + Suprimeix per a tothom + Missatges directes + Missatges que desapareixen + Els missatges que desapareixen estan prohibits en aquest xat. + %dd + %d dia + %d dies + Suprimeix després + %dh + %d hora + Els missatges directes entre membres estan prohibits. + Els missatges directes entre membres estan prohibits en aquest grup. + Els missatges que desapareixen estan prohibits. + %dm + %d min + %d mes + %d mesos + %dmes + No envieu l\'historial als nous membres. + Missatges que desapareixen + Diferents noms, avatars i aïllament del transport. + Descobreix i uneix-te a grups + Suprimir fins a 20 missatges alhora. + dies + Suprimeix o modera fins a 200 missatges. + Rebuts de lliurament! + Els rebuts de lliurament estan desactivats! + La versió de l\'aplicació d\'ordinador %s no és compatible amb aquesta aplicació. + Ordinadors + Dispositius + Desconnectar + Desconnectar l\'ordinador? + S\'ha desconnectat amb el motiu: %s + Desconnectar mòbils + Ordinador + Adreça ordinador + L\'ordinador té una versió no compatible. Si us plau, assegureu-vos que feu servir la mateixa versió als dos dispositius + L\'ordinador té un codi d\'invitació incorrecte + L\'ordinador està ocupat + L\'ordinador està inactiu + L\'ordinador ha estat desconnectat + Descobrible a través de la xarxa local + Descobriu a través de la xarxa local + %s]]> + %1$s!]]> + %1$s.]]> + %1$s.]]> + Suprimir la base de dades d\'aquest dispositiu + No heu d\'utilitzar la mateixa base de dades en dos dispositius. + Detalls + errors de desxifrat + Estadístiques detallades + Suprimides + Errors d\'eliminació + Mòbil connectat + Connectar en mode incògnit + connexió %1$d + Comprova si hi ha missatges cada 10 minuts durant un minut com a màxim + Canvia el codi d\'accés + Canvia el perfil + Confirmeu les credencials + Xats + Parleu amb els desenvolupadors + connecta + Confirmeu l\'eliminació del contacte? + Connectat + Confirma + Voleu suprimir les notes privades? + Botó de tancament + Connecta + Consola del xat + Comproveu l\'adreça del servidor de i torneu a provar. + Servidors SMP configurats + Configura els servidors ICE + Perfil del xat + Connexió + Comprova si hi ha actualitzacions + Comprova si hi ha actualitzacions + connectat + Comprova missatges cada 10 minuts + Canvia la modalitat de bloqueig + Canvia el mode l\'autodestrucció + Canvia el codi d\'autodestrucció + Confirmeu el codi d\'accés + BASE DE DADES DELS XATS + XATS + Tema del xat + Colors del xat + Base de dades suprimida + Base de dades importada + Base de dades exportada + Confirmeu el nou mot de pas… + Voleu canviar la frase de pas per a la base de dades? + connectat + connectat directament + canviant d\'adreça per %s… + connectat + complet + Canvia + Xat + Condicions acceptades el: %s. + Condicions d\'ús + Mode de color + Preferències del xat + Compareu els codis de seguretat amb els vostres contactes. + Estat de la connexió i dels servidors + Connectat a un mòbil + Pròximament! + Connecta automàticament + El xat ja existeix! + Xat migrat! + Comproveu la connexió a Internet i torneu a provar + Servidors connectats + Blocs eliminats + Blocs descarregats + Error en desar el fitxer + Canviar l\'adreça de recepció? + connectar amb els desenvolupadors de SimpleX Chat per fer qualsevol pregunta i rebre actualitzacions.]]> + Obre a l\'aplicació mòbil.]]> + Error en desar els servidors ICE + Error en desar el servidor intermediari + BASE DE DADES DELS XATS + El xat s\'està executant + El xat està aturat + Error: %s + El xat està aturat + El xat està aturat. Si ja heu utilitzat aquesta base de dades en un altre dispositiu, hauríeu de tornar-la a transferir abans d\'iniciar el xat. + ha canviat el rol de %s a %s + ha canviat el teu rol a %s + ha canviat d\'adreça per a tu + canviant d\'adreça… + canviant d\'adreça… + El xat se suprimirà; això no es pot desfer! + Canviar el rol del grup? + Canviar rol + Canvia l\'adreça de recepció + Error en desar el perfil del grup + Mòbil + Errors + Contacte verificat + El contacte i tots els missatges s\'eliminaran, l\'acció és irreversible! + connexió establerta + Error de connexió + Temps de connexió enhaurit + El contacte la existeix + Error de connexió (AUTH) + Nom del contacte + Contacte eliminat! + El contacte encara no s\'hi ha connectat! + Sol·licitud de connexió tramesa! + Connecta via enllaç + el contacte admet criptografia d\'extrem a extrem + el contacte no té criptografía d\'extrem a extrem + El contacte ha estat eliminat + Preferències del contacte + Connecteu amb els vostres amic més ràpidament. + Connexió interrompuda + Connexió aturada + Connexió finalitzada + Voleu connectar a través d\'un enllaç? + Connexions + Error en carregar l\'arxiu + Error en desar la configuració + Error en aturar el xat + Error en actualitzar el servidor + Introduïu la contrasenya + Error en iniciar el xat + Error en desar la contrasenya de l\'usuari + Introduïu la contrasenya correcta. + Introduïu la contrasenya… + Introduïu el nom del grup: + Error en verificar la contrasenya: + finalitzada + Introduïu la contrasenya + error en mostrar el contingut + error en mostrar el missatge + Error en desar els servidors SMP + Error en desar els servidors XFTP + Error en enviar el missatge + Errors en la configuració dels servidors. + Error en canviar el perfil! + Error en actualitzar la configuració de la xarxa + Error en establir l\'adreça + Error en sincronitzar la connexió + Error en actualitzar la privadesa de l\'usuari + Error en mostrar la notificació, contacteu amb els desenvolupadors. + Introduïu el codi + Error en canviar el perfil + Introduïu el servidor manualment + Error en enviar la invitació + Error en actualitzar l\'enllaç del grup + Fins i tot quan està desactivat a la conversa. + Continua + Continua + Continua + Icona contextual + El contacte serà eliminat; l\'acció no es pot desfer! + Conversa eliminada! + Copiat al porta-retalls + Contribuïu + Contactes + Error en restablir les estadístiques + Error + Error en reconnectar els servidors + Introduïu el missatge de benvinguda… (opcional) + Introduïu el vostre nom: + Error en obrir el navegador + Error en exportar la base de dades de xat + S\'ha produït un error en suprimir la base de dades de xat + Error en importar la base de dades de xat + Error en canviar la configuració + Error en eliminar el membre + Error en bloquejar el membre per a tots + Error en canviar el rol + Error en afegir servidor + Error en suprimir la base de dades + Error en reconnectar el servidor + Error en xifrar la base de dades + Error en exportar la base de dades de xat + Blocs penjats + El xat se suprimirà per a tots els membres; això no es pot desfer! + acolorit + Feu clic al botó d\'informació al costat del camp d\'adreça per permetre l\'ús del micròfon. + Completades + Error en acceptar les condicions + Error en connectar-se al servidor de reenviament %1$s. Si us plau, prova-ho més tard. + Error en crear el missatge + Error en crear el perfil! + Error en reenviar els missatges + Error en carregar els servidors SMP + Error en carregar els servidors XFTP + Error en afegir membre(s) + Error en crear l\'adreça + Error en unir-se al grup + Error en carregar els detalls + Error en rebre el fitxer + Error en acceptar la sol·licitud de contacte + S\'ha produït un error en suprimir el contacte + S\'ha produït un error en suprimir el grup + Error en cancel·lar el canvi d\'adreça + Error en canviar l\'adreça + S\'ha produït un error en suprimir la sol·licitud de contacte + S\'ha produït un error en suprimir la connexió de contacte pendent + S\'ha produït un error en suprimir les notes privades + S\'ha produït un error en suprimir el perfil d\'usuari + Comparar el fitxer + Error: %1$s + Error + Trieu un fitxer + Netejar + Netejar + Netejar xat + Netejar xat? + Netejar verificació + Error en inicialitzar WebView. Assegureu-vos que teniu WebView instal·lat i que sigui suportada l\'arquitectura arm64. Error: %s + Error en inicialitzar WebView. Actualitzeu el vostre sistema a la nova versió. Poseu-vos en contacte amb els desenvolupadors. Error: %s + Netejar + Error en crear l\'enllaç del grup + Error en crear el contacte del membre + Error en suprimir l\'enllaç del grup + Introduïu el missatge de benvinguda… + Interfície en xinès i espanyol + Error en habilitar els rebuts de lliurament. + Introduïu el nom d\'aquest dispositiu… + Error + Error en baixar l\'arxiu + Crea una adreça + Crea + Crea un grup tot fent servir un perfil aleatori. + Perfil actual + Crea un enllaç d\'un sol ús + No crear cap adreça + Crea + Crea perfil + Cantonada + Frase de pas actual… + Crea un enllaç + Crea un grup secret + No tornis a mostrar + Tema fosc + Creat + Crea un grup + Crea un enllaç de group + Errada crítica + personalitzat + Mode fosc + Crea una cua + Crea un fitxer + Codi d\'accés actual + Ara per ara, la mida màxima per als fitxers és %1$s. + Crea un enllaç d\'invitació d\'un sol ús + Crea un grupo secreto + Temps personalitzat + Crea un perfil de xat + Creeu una adreça perquè la gent pugui connectar amb vós. + Crea una adreça SimpleX + Crea perfil + creador + ID de la base de dades + ID de la base de dades: %d + (actual) + Fosc + Fosc + Creeu el vostre perfil + editat + SimpleX Chat i Flux han acordat incloure els servidors operats per Flux a l\'aplicació. + Voleu compartir l\'adreça de SimpleX o un enllaç d\'un sol ús? + Configuració + Tanca + s\'està connectant… + Les condicions s\'acceptaran per als operadors habilitats després de 30 dies. + SimpleX no pot funcionar en segon pla. Només rebreu les notificacions quan obriu l\'aplicació. + Trucades de SimpleX chat + Missatges de xat de SimpleX + enviat + per llegir + Benvinguts! + Envia + Envia + Restableix + Fitxer + Imatge + Vídeo + Silencia + Activa el so + Favorit + Configuració + ajuda + Correu electrònic + Adreça de SimpleX + Més + Compartir els enllaços d\'un sol ús i les adreces de SimpleX és segur a través de qualsevol mitjà. + Enganxa + Desa + Torna-ho a provar + Adreça de SimpleX + Desa + Nom d\'usuari + Contrasenya + Amfitrió + Port + No + Requerit + Amb IP desprotegida + Mai + + No + Estable + Mostra: + Amaga: + SimpleX + La seguretat de SimpleX chat ha estat auditada per Trail of Bits. + Parlem a SimpleX Chat + El nom no és vàlid! + cursiva + Crea una connexió privada + Trucada d\'àudio entrant + Codi d\'accés incorrecte + Bloca després + Mode de blocatge + Mode d\'incògnit + inactiu + Mode clar + Grups d\'incògnit + MEMBRE + Voleu unir-vos al grup? + Surt + Voleu sortir del xat? + Voleu sortir del grup? + Invitació caducada! + Convida membres + convidat + ha sortit + Protegeix la vostra adreça IP i les vostres connexions. + So de trucada + Videotrucada entrant + indirecte (%1$s) + invitació al grup %1$s + Convida + Versió incompatible + Interfície en lituà + Error intern + Augmenta la mida de la lletra. + Nom local + Enllaç no vàlid + Convida + Convida amics + Interfície en italià + Ruta de fitxer no vàlida + dades no vàlides + En resposta a + Descarrega + Membre inactiu + Conserva la conversa + Marcar com ha llegit + Enllaç no vàlid + El codi QR no és vàlid + Més informació + El codi QR no és vàlid + Codi de seguretat incorrecte! + L\'adreça del servidor no és vàlida! + Ajuda sobre Markdown + Markdown en els missatges + Assegureu-vos que la configuració del servidor intermediari és correcta. + Instal·la l\'actualització + "Instal·lació completada" + Mitjà + La versió de la base de dades és incompatible + ha convidat %1$s + ha sortit + membre + Convida al grup + Surt del xat + Surt del grup + Incògnit + El mode d\'incògnit protegeix la vostra privacitat usant un perfil aleatori nou per a cada contacte. + Clar + Clar + Els membres poden enviar missatges directes. + Els membres poden enviar fitxers i multimèdia. + Corregir el nom a %s? + connectant… + Trucada amb xifratge d\'extrem a extrem + Videotrucada amb xifratge d\'extrem a extrem + xifrat d\'extrem a extrem + Activa les trucades des de la pantalla de bloqueig mitjançant Configuració. + missatge duplicat + Activa bloqueig + Activar (conservar anul·lacions) + Activa (mantenir les anul·lacions de grup) + Activar la supressió automàtica de missatges? + Creat a + Connectar-se directament? + Connexió + El text de les condicions actuals no s\'ha pogut carregar, podeu revisar les condicions mitjançant aquest enllaç: + Trucada en connexió + Baixant l\'arxiu + %d setmana + Confirmeu la contrasenya + connectant (presentat) + connectant (anunciat) + connectant (invitació de presentació) + %ds + %d setmanes + Baixa noves versions de GitHub. + Habiliteu Flux a la configuració de la xarxa i dels servidors per obtenir una millor privadesa de les metadades. + Connectar amb l\'ordinador + Connectant + Errors de descàrrega + Confirmeu fitxers de servidors desconeguts. + Les condicions s\'acceptaran el dia: %s. + Connectades + Arxius descarregats + Descarregat + connectant (acceptat) + Ordinador connectat + Connectat amb l\'ordinador + trucada en connexió… + Creant un enllaç a l\'arxiu + Connectant amb l\'ordinador + - Connexió al servei de directoris (BETA)!\n- Confirmacions de lliurament (fins a 20 membres).\n- Més ràpid i més estable. + Habilita per a tothom + el contacte %1$s ha canviat a %2$s + El contacte permet + %ds + duplicats + Editar imatge + Activar + Habilita per a tots els grups + Habilita als xats directes (BETA)! + Copiar error + La baixada ha fallat + %d seg + Connectar mitjançant l\'adreça de contacte? + Conectar mitjançant enllaç d\'invitació? + Nom de visualització duplicat! + Descarregar fitxer + Contacte ocult: + %d segons + connectant… + Connectar amb %1$s? + Descarregar + La connexió requereix una renegociació del xifratge. + Connectar mitjançant enllaç/codi QR + Habilita l\'accés a la càmera + Seguretat de connexió + Creant enllaç… + Servidors XFTP configurats + No utilitzeu credencials amb servidor intermediari. + Versió del nucli: v%s + Personalitzar tema + NO utilitzeu l\'encaminament privat. + S\'està baixant l\'actualització de l\'aplicació, no la tanquis + Baixa %s (%s) + ID de bases de dades i opció d\'aïllament de transport. + Auriculars + Error de base de dades + Confirmeu les actualitzacions de la base de dades + Reducció de versió de la base de dades + Baixa la versió anterior i obri el xat + connectant + Editar perfil del grup + Creat a: %s + Connectant al contacte, si us plau, espereu o comproveu-ho més tard! + Les condicions s\'acceptaran automàticament per als operadors habilitats el dia: %s. + Colors en mode fosc + habilitat per al contacte + habilitat + habilitat per a tu + Els contactes poden marcar missatges per suprimir-los. Encara els podreu veure. + Habilitat per + Personalitza i comparteix temes de color. + Temes personalitzats + Crea perfil nou a l\'aplicació per a ordinador. 💻 + Forma de missatge personalitzable. + No activeu + Connectar amb tu mateix? + Confirmeu la configuració de la xarxa + Confirmeu que recordeu la contrasenya de la base de dades per migrar-la. + La connexió a l\'ordinador és deficient + Confirmar la càrrega + Controla la teva xarxa + Baixant els detalls de l\'enllaç + Error en desar els servidors + Habilita els registres + Per a xarxes socials + Nom complet: + Sortir sense desar + Contrasenya de perfil amagada + Concedir permisos + Per exemple, si el vostre contacte rep missatges mitjançant un servidor SimpleX Chat, la vostra aplicació els lliurarà mitjançant un servidor Flux. + Gira la càmera + Activar codi d\'autodestrucció + Activar els rebuts? + Activar autodestrucció + Activar els rebuts per a grups? + FITXERS + EXPERIMENTAL + Exportar base de dades + Xifrar + Fitxer: %s + Grup + Arreglar connexió + Totalment descentralitzat: només visible per als membres. + Moderació de grups + Missatge de benvinguda als grups + Grup inactiu + La invitació de grup ja no és vàlida, el remitent l\'ha eliminada. + Grup no trobat! + es requereix renegociar el xifratge + grup esborrat + PER A CONSOLA + Arreglar connexió? + Correcció no suportada per membre del grup + Nom complet del grup: + Farciment + Encaix + Enllaços de grup + Perfils de xat ocults + Xifra els fitxers i els mitjans emmagatzemats + Ordinador trobat + El grup ja existeix! + Finalitzar la migració + expirats + xifratge acordat + Unió més ràpida i missatges més fiables. + Activar TCP keep-alive + S\'ha suprimit el fitxer o l\'enllaç no és vàlid + Error en desar la base de dades + xifratge ok + Penjar + Bon dia! + Preferències del grup + Fitxers + Correcció no suportada pel contacte + Arreglar + per a una millor privadesa de les metadades. + Bona tarda! + El fitxer exportat no existeix + Reenvia i desa missatges + Hola!\nConnecteu-vos amb mi mitjançant SimpleX Chat: %s + Característiques experimentals + La renegociació del xifratge ha fallat. + renegociació de xifratge permesa + Error de renegociació de xifratge + Enllaç complet + No s\'ha pogut carregar el xat + No s\'han pogut carregar els xats + Per al perfil de xat %s: + El servidor de reenviament %1$s no s\'ha pogut connectar al servidor de destinació %2$s. Si us plau, prova-ho més tard. + L\'adreça del servidor de reenviament és incompatible amb la configuració de xarxa: %1$s. + La versió del servidor de reenviament és incompatible amb la configuració de xarxa: %1$s. + L\'execució de la funció triga massa temps: %1$d segons: %2$s + Trucada finalitzada + Activar Bloqueig SimpleX + Servidor de reenviament: %1$s\nError: %2$s + Servidor de reenviament: %1$s\nError del servidor de destinació: %2$s + No s\'ha trobat el fitxer: el més probable és que s\'hagi suprimit o cancel·lat. + Error del servidor de fitxers: %1$s + Reenviat des de + Per a tothom + El fitxer s\'eliminarà dels servidors. + Reenviar + Reenviar %1$s missatges? + Reenviar missatges sense fitxers? + Reenviar missatge… + Reenviar missatges… + Arxius i mitjans prohibits! + Reenviant %1$s missatges + No es permeten fitxers ni suports + Fitxer + Error de fitxer + No s\'ha trobat el fitxer + Fitxer desat + El fitxer es rebrà quan el vostre contacte acabi de carregar-lo. + El fitxer es rebrà quan el vostre contacte estigui en línia, espereu o comproveu més tard! + Arreglar connexió? + Arreglar + Renegociació de xifratge en curs. + De la Galeria + Cerqueu aquest permís a la configuració d\'Android i concediu-lo manualment. + Concedir a la configuració + Donar permís(os) per fer trucades + Auriculars + Xifra fitxers locals + AJUT + Arxius i mitjans + Xifrar base de dades? + Base de dades xifrada + Invitació del grup caducada + perfil de grup actualitzat + xifratge acordat per a %s + xifratge correcte per a %s + renegociació de xifratge permesa per a %s + es requereix renegociar el xifratge per a %s + Amplieu la selecció de rols + El grup se suprimirà per a tots els membres; això no es pot desfer! + Enllaç de grup + El grup se suprimirà per tu; això no es pot desfer! + Estat del fitxer + Estat del fitxer: %s + El perfil del grup s\'emmagatzema als dispositius dels membres, no als servidors. + Per a l\'encaminament privat + xifratge correcte + Mida de la lletra + Arxius i mitjans + Els fitxers i els mitjans estan prohibits. + Interfície en francès + Ràpid i sense esperar fins que el remitent estigui en línia! + Per fi, els tenim! 🚀 + Ús de la bateria encara més reduït + Filtra els xats preferits i no llegits. + Trobar xats més ràpidament + Corregir el xifratge després de restaurar les còpies de seguretat. + Reenvia fins a 20 missatges alhora. + Finalitzar la migració a un altre dispositiu. + trucada perduda + Micròfon + Trucada perduda + Esborrany + Mòbils remots + Elimina + Expulsa + t\'ha expulsat + eliminat + Renegocia l\'encriptació + mesos + Migra aquí + Missatges enviats + Missatges rebuts + Expulsar membre + minuts + ha expulsat %1$s + moderat per %s + Error en l\'enviament del missatge + El missatge podrà ser lliurat si el membre esdevé actiu. + Missatge + El missatge és massa llarg! + missatge + MISSATGES I FITXERS + Missatges + Estat del missatge + Estat del missatge: %s + Missatge massa llarg + Menús i alertes + Eliminar la imatge + Els membres poden enviar missatges de veu. + Voleu desar les preferències? + Desa + Desa la frase de pas al Keystore + Desa la frase de pas i obre el xat + Desa i reconnecta + Repeteix + Grups més segurs + Repeteix la descàrrega + Repetir importació + Restaura la còpia de la base de dades + %s i %s + Restaura + Voleu restaurar la còpia de la base de dades? + Solament quan l\'app és oberta + Voleu revocar el fitxer? + Voleu renegociar l\'encriptació? + Desa els servidors + Missatge desat + Desa el perfil del grup + Esperant connexió mòbil: + Podeu amagar o silenciar un perfil d\'usuari; manteniu-lo premut per al menú. + Ja esteu connectant mitjançant aquest enllaç d\'un sol ús! + Pots compartir un enllaç o un codi QR; qualsevol es podrà unir al grup. No perdràs membres del grup si més tard el suprimeixes. + Podeu configurar servidors mitjançant la configuració. + Pots intentar-ho un altre cop. + La vostra base de dades de xat actual s\'ELIMINARÀ i SUBSTITUÏRÀ per la importada.\nAquesta acció no es pot desfer: el vostre perfil, contactes, missatges i fitxers es perdran de manera irreversible. + has compartit enllaç d\'un sol ús en mode incògnit + Repetir la sol·licitud de connexió? + Sense informació, prova de tornar a carregar + Pot passar quan:\n1. Els missatges van caducar al client d\'enviament al cap de 2 dies o al servidor després de 30 dies.\n2. No s\'ha pogut desxifrar el missatge, perquè tu o el teu contacte feien servir una còpia de seguretat de la base de dades antiga.\n3. La connexió s\'ha compromès. + Només dades de perfil local + Unir-se al teu grup? + El missatge es marcarà per suprimir-lo. Els destinataris podran visualitzar aquest missatge. + Nova experiència de xat 🎉 + Unir-te + Enganxar enllaç per connectar! + Arxiu de bases de dades antigues + Migrar des d\'un altre dispositiu + Les notificacions només es lliuraran fins que l\'aplicació s\'aturi! + Obrir canvis + el membre %1$s ha canviat a %2$s + Canvia l\'aspecte dels teus xats! + Màxim 40 segons, rebut a l\'instant. + Només vos podreu suprimir els missatges de manera irreversible (el vostre contacte pot marcar-los per suprimir-los). (24 hores) + Obrir configuració del servidor + altres errors + - Notificació opcional als contactes suprimits.\n- Noms de perfil amb espais.\n- I més! + Pendents + Els missatges se suprimiran; això no es pot desfer! + Connexió de xarxa més fiable. + Nou dispositiu mòbil + - El xat s\'obre amb al primer missatge no llegit.\n- Desplaçaments fins als missatges citats. + Obriu Configuració de Safari / Llocs web / Micròfon i, a continuació, trieu Permetre localhost. + Servidor de l\'operador + O enganxeu l\'enllaç de l\'arxiu + O compartiu aquest enllaç de fitxer de manera segura + enviament fallat + L\'enviament de rebuts de lliurament s\'habilitarà per a tots els contactes. + Adreça del servidor + %s pujades + Connexió TCP + Mode d\'incògnit simplificat + El rol es canviarà a %s. Tots els participants del xat rebran una notificació. + Tancar? + Les notificacions deixaran de funcionar fins que torneu a iniciar l\'aplicació + Els teus contactes romandran connectats. + Compartir l\'adreça amb els contactes? + Compartir enllaç + Desa la configuració d\'acceptació automàtica + Deixar de compartir + O per compartir en privat + Podeu crear-la més tard + Podeu fer-lo visible per als vostres contactes de SimpleX mitjançant Configuració. + Nom del perfil: + Desa i notifica el contacte + El teu perfil actual + El teu perfil s\'emmagatzema al teu dispositiu i només es comparteix amb els teus contactes, els servidors SimpleX no poden veure\'l. + Desa i notifica els contactes + Desa i notifica als membres del grup + Amagar el perfil + Contrasenya per mostrar + Desa la contrasenya del perfil + Per revelar el vostre perfil ocult introduïu una contrasenya completa al camp de cerca de la pàgina Els vostres perfils de xat. + Tu controles el teu xat! + Pots utilitzar la sintaxi markdown per donar format als teus missatges: + Com utilitzar la sintaxis markdown + trucada rebutjada + iniciant… + resposta rebuda… + esperant resposta… + esperant confirmació… + confirmació rebuda… + Un navegador web predeterminat és necessari per a les trucades. Configura\'n un al sistema i comparteix més informació amb els desenvolupadors. + Privadesa redefinida + Tu decideixes qui es pot connectar. + Immune al correu brossa + Sense identificadors d\'usuari. + Per protegir la vostra privadesa SimpleX utilitza identificadors separats per a cadascun dels vostres contactes.. + Obrir SimpleX + Com funciona SimpleX + Només els dispositius client emmagatzemen perfils d\'usuari, contactes, grups i missatges. + Notificacions privades + Com afecta la bateria + Periòdic + Instantánea + Quan l\'aplicació s\'està executant + Sense servei de fons + L\'aplicació protegeix la teva privadesa utilitzant diferents operadors en cada conversa. + Quan hi ha més d\'un operador habilitat, cap d\'ells té metadades per saber qui es comunica amb qui. + Com ajuda a la privadesa + Revisar més tard + Actualitzar + Podeu configurar els operadors a la configuració: Xarxa i Servidors. + videotrucada (sense xifrar) + Ignorar + Rebutjar + Per fer trucades, permet utilitzar el micròfon. Finalitza la trucada i prova de tornar a trucar. + Videotrucada + El servidor de retransmissió només s\'utilitza si cal. Un tercer pot observar la vostra adreça IP. + Mostrar + Trucades + sense xifratge e2e + Obrir + mitjançant retransmissor + Desactivar vídeo + p2p + Activar vídeo + Trucada rebutjada + Trucada pendent + So silenciat + Missatges omesos + El hash del missatge anterior és diferent.\" + L\'identificador del missatge següent és incorrecte (menor o igual a l\'anterior).\nPot passar per algun error o quan la connexió està compromesa. + Informeu-ho als desenvolupadors. + Pot passar quan tu o el teu contacte feu servir la còpia de seguretat de la base de dades antiga. + L\'aplicació us demanarà que confirmeu les baixades de servidors de fitxers desconeguts (excepte .onion o quan el servidor intermediari SOCKS estigui habilitat). + Envia previsualitzacions d\'enllaços + Mostra els últims missatges + Enviar + Codi nou + Desactivat + Codi d\'accés + Codi d\'accés desat! + Codi d\'accés canviat! + Nou nom mostrat: + Codi d\'autodestrucció canviat! + Codi d\'autodestrucció activat! + L\'enviament de rebuts està desactivat per a %d contactes + L\'enviament de rebuts està habilitat per a %d contactes + L\'enviament de rebuts està habilitat per a %d grups + ENVIAR ELS REBUS DE LLIURAMENT A + L\'enviament de rebuts està desactivat per a %d grups + Reiniciar + SERVIDOR INTERMEDIARI SOCKS + Imatges de perfil + TEMES + Cua + Forma del missatge + EXECUTAR SIMPLEX + Usar des d\'ordinador + Base de dades de xat + Importar base de dades + Nou arxiu de bases de dades + Obrir la carpeta de la base de dades + Aturar SimpleX? + Atura SimpleX per poder exportar, importar o suprimir la base de dades de xat. No podreu rebre ni enviar missatges mentre el xat estigui aturat. + Estableix contrasenya per a exportar + Aturar + Reinicieu l\'aplicació per utilitzar la base de dades de xat importada. + Importar + Importar la base de dades de xat? + S\'han produït alguns errors no fatals durant la importació: + Aquesta acció no es pot desfer: el vostre perfil, contactes, missatges i fitxers es perdran de manera irreversible. + Aquesta acció no es pot desfer; els missatges enviats i rebuts anteriors al seleccionat se suprimiran. Pot trigar uns quants minuts. + Podeu migrar la base de dades exportada. + Podeu desar l\'arxiu exportat. + Desa la contrasenya a la configuració + Actualitzar + Error desconegut + Contrasenya incorrecta! + Aquest grup ja no existeix. + ha desblocat a %s + %s, %s i %d membres més connectats + has canviat d\'adreça de servidor per a %s + El membre s\'eliminarà del xat; això no es pot desfer! + El membre s\'eliminarà del grup; això no es pot desfer! + El rol es canviarà a %s. Tots els membres del grup seran avisats. + La sol·licitud de connexió s\'enviarà a aquest membre del grup. + El rol es canviarà a %s. El membre rebrà una nova invitació. + Estat de la xarxa + Operador + Lloc web + Per a enviar + Restablir valors predeterminats + Compte PING + Toqueu per activar el perfil. + Silenciat quan està inactiu! + Encara rebràs trucades i notificacions de perfils silenciats quan estiguin actius. + Només el vostre contacte pot suprimir missatges de manera irreversible (podeu marcar-los per suprimir-los). (24 hores) + La supressió de missatges irreversible està prohibida en aquest xat. + Els vostres contactes poden permetre la supressió completa del missatge. + Eliminació irreversible del missatge + Amaga la pantalla de l\'aplicació a les aplicacions recents. + Millora de la privadesa i la seguretat + Configuració del servidor millorada + El(s) destinatari(s) veu(en) l\'actualització mentre l\'escriviu. + Missatges en directe + Els missatges enviats se suprimiran després del temps establert. + Esborrany de missatge + Conserva l\'últim esborrany del missatge, amb fitxers adjunts. + Aïllament de transport + Ara els administradors poden:\n- suprimir els missatges dels membres.\n- desactivar els membres (rol d\'observador) + Protegir els vostres perfils de xat amb contrasenya! + Admet bluetooth i altres millores. + Mitjançant protocol segur de resistència quàntica. + Enllaça aplicacions mòbils i d\'ordinador! 🔗 + Migrar a un altre dispositiu + Preparant càrrega + Sense connexió de xarxa + Sessions de transport + Assegurades + Usar l\'aplicació durant la trucada. + Desblocar per tothom + Els missatges es marcaran com a moderats per a tots els membres. + Utilitzar %s + Suprimir la contrasenya de Keystore? + Suprimir la contrasenya de la configuració? + Nova contrasenya… + Introduïu la contrasenya actual correcta. + Estableix contrasenya per a la base de dades + Estableix contrasenya + Actualitzar la contrasenya de la base de dades + La frase de contrasenya s\'emmagatzema a la configuració com a text pla. + L\'intent de canviar la contrasenya de la base de dades no s\'ha completat. + Error de base de dades desconegut: %s + Error en restaurar base de dades + Migracions: %s + Avís: podeu perdre algunes dades! + Podeu iniciar el xat mitjançant Configuració / Base de dades o reiniciant l\'aplicació. + Uneix-te d\'incògnit + Entrant al grup + Estàs convidat al grup. Uneix-te per connectar amb els seus membres. + T\'has unit a aquest grup. S\'està connectant amb l\'emissor de la invitació. + Deixaràs de rebre missatges d\'aquest xat. L\'historial de xat es conservarà. + Deixaràs de rebre missatges d\'aquest grup. L\'historial de xat es conservarà. + %s i %s connectats + %s, %s i %d membres + Obrir + %s, %s i %s connectats + adreça de contacte eliminat + foto de perfil eliminada + establir una nova foto de perfil + nova adreça de contacte + perfil actualitzat + has canviat d\'adreça de servidor + propietari + estat desconegut + Podeu compartir aquesta adreça amb els vostres contactes perquè es connectin amb %s. + Aquest grup té més de %1$d membres, no s\'envien rebuts d\'entrega. + Rebut + %s a les %s + Enviar missatge directe + Moderat el: %s + Rebut: %s + Desblocar membre + Desblocar membre? + Desblocar membre per tothom? + Rol + informació de cua del servidor: %1$s\n\núltim missatge rebut: %2$s + Informació de la cua de missatges + cap + Enviant via + El teu perfil de xat s\'enviarà als membres del xat + El teu perfil de xat s\'enviarà als membres del grup + Operador de xarxa + %s servidors + Servidors per a fitxers nous al vostre perfil de xat actual + Veure condicions + Obrir condicions + Servidor afegit a l\'operador %s. + Ha canviat l\'operador del servidor. + El protocol del servidor ha canviat. + Temps d\'espera de connexió TCP + Temps d\'espera del protocol + Temps d\'espera del protocol per KB + Concurrència a la recepció + Actualitzar la configuració de xarxa? + Actualitzar + Connexions de perfil i servidor + Mostrar + Silenciar + Activar so + Mostrar perfil + Restablir color + Restablir colors + Mode de sistema + no + desactivat` + Activat + Restablir al tema de l\'aplicació + Restablir al tema d\'usuari + Estableix tema predeterminat + Permeteu + Estableix preferències de grup + Preferències + Reaccions a missatge + Missatges de veu + Prohibir l\'enviament de fitxers i mitjans. + Enllaços SimpleX no permesos. + S\'envien fins a 100 darrers missatges als nous membres. + L\'historial no s\'envia als nous membres. + oferit %s + oferit %s: %2s + Novetats + Nou a %s + Llegeix més + Missatges de veu + Amb missatge de benvinguda opcional. + Noms de fitxers privats + Ús de bateria reduït + Gràcies als usuaris: contribuïu a través de Weblate! + Aviat hi haurà més millores! + Rebuts de lliurament de missatges! + Activa o desactiva l\'incògnit en connectar-te. + Per amagar missatges no desitjats. + Notes privades + La barra de cerca accepta enllaços d\'invitació. + Amb fitxers i mitjans xifrats. + Entrega de missatges millorada + Amb ús reduït de la bateria. + Migra a un altre dispositiu mitjançant el codi QR. + Trucades imatge-en-imatge + Xifratge resistent quàntic + Formar imatges de perfil + L\'autoria del missatge continua sent privada. + Quadrat, cercle o qualsevol forma intermèdia. + En connectar trucades d\'àudio i vídeo. + S\'habilitarà als xats directes! + Gestió de la xarxa + Nous temes de xat + Rebre fitxers amb seguretat + Entrega de missatges millorada + IU en persa + Amb ús reduït de la bateria. + Noves opcions de mitjans + Barres d\'eines de xat accessible + Fes servir l\'aplicació amb una sola mà. + Reprodueix des de la llista de xat. + Actualitza l\'aplicació automàticament + Descentralització de la xarxa + El segon operador preestablert a l\'aplicació! + Privadesa per als teus clients. + Veure condicions actualitzades + hores + setmanes + L\'enviament de rebuts de lliurament s\'habilitarà per a tots els contactes de tots els perfils de xat visibles. + Desenllaçar + Desenllaçar l\'ordinador? + Podeu activar-los més tard mitjançant la configuració de privadesa i seguretat de l\'aplicació. + Aquest enllaç s\'ha utilitzat amb un altre dispositiu mòbil; creeu-ne un de nou a l\'ordinador. + Només un dispositiu pot funcionar al mateix temps + Codi de sessió + Esperant ordinador… + Ordinadors enllaçats + Verifica el codi a l\'ordinador + S\'ha arribat al temps d\'espera durant la connexió a l\'ordinador + Ja heu sol·licitat connexió a través d\'aquesta adreça! + Repetir la sol·licitud d\'unió? + Grup obert + Ja t\'estàs unint al grup mitjançant aquest enllaç. + Informeu-ho als desenvolupadors:\n%s\n\nEs recomana reiniciar l\'aplicació. + O importar un fitxer d\'arxiu + Enganxa l\'enllaç de l\'arxiu + Reiniciar xat + Migrant + Preparant descàrrega + Importació fallida + Important arxiu + Aturant el xat + Migrar dispositiu + Per continuar, el xat s\'ha d\'aturar. + Carregant l\'arxiu + Suprimir l\'arxiu? + Repetir la càrrega + Pots intentar-ho un altre cop. + L\'arxiu de base de dades carregat s\'eliminarà permanentment dels servidors. + Migració completada + Verificar contrasenya + Altre + Info servidors + Mostrant info per a + Iniciat el %s\nTotes les dades es mantenen privades al vostre dispositiu. + Total + No esteu connectats a aquests servidors. Per enviar missatges s\'usa l\'encaminament privat. + Reconnectar servidors? + Restablir + Restablir totes les estadístiques + Restablir totes les estadístiques? + Les estadístiques dels servidors es restabliran; això no es pot desfer! + Carregat + Missatges enviats + Missatges rebuts + Total rebuts + Total enviats + Iniciat el %s. + Errors de recepció + Reconnectar + Errors d\'enviament + altres + Intermediat + Enviat directament + Enviat mitjançant servidor intermediari + Subscrit + Errors de subscripció + Subscripcions ignorades + Fitxers carregats + Errors de càrrega + Utilitzar servidors + Rebuts desactivats + Reconnectar els servidors per forçar l\'entrega de missatges. Utilitza trànsit addicional. + Reconnectar servidor? + Reconnectar tots els servidors + Comproveu que el mòbil i l\'ordinador estiguin connectats a la mateixa xarxa local i que el tallafoc d\'escriptori permet la connexió.\nSi us plau, comparteix qualsevol altre problema amb els desenvolupadors. + Introduïu la contrasenya anterior després de restaurar la còpia de seguretat de la base de dades. Aquesta acció no es pot desfer. + Interval PING + Informeu-ho als desenvolupadors:\n%s + Encaminament de missatges privat 🚀 + Amagar + Reconnectar el servidor per forçar l\'entrega de missatges. Utilitza trànsit addicional. + Verificar contrasenya de la base de dades + Navegació millorada al xat + Recepció de missatges + Enllaç no vàlid + Unir-te al grup? + marcat eliminat + Obrint la base de dades… + Comproveu que l\'enllaç SimpleX sigui correcte. + l\'enviament de fitxers encara no està suportat + Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. + S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). + Usar perfil actual + Usar nou perfil incògnit + Error aplicació + Esteu connectat al servidor utilitzat per rebre missatges d\'aquest contacte. + El teu perfil s\'enviarà al contacte del qual has rebut aquest enllaç. + Heu compartit una ruta de fitxer no vàlida. Informeu-ne als desenvolupadors de l\'aplicació. + Us connectareu amb tots els membres del grup. + d\'incògnit mitjançant l\'enllaç de l\'adreça de contacte + d\'incògnit mitjançant l\'enllaç del grup + d\'incògnit mitjançant un enllaç d\'un sol ús + xat no vàlid + format de missatge no vàlid + convidat a connectar + L\'obertura de l\'enllaç al navegador pot reduir la privadesa i la seguretat de la connexió. Els enllaços SimpleX no fiables seran vermells. + Notes privades + la recepció de fitxers encara no està suportada + sol·licitada connexió + desat des de %s + Adreça de contacte SimpleX + Enllaç de grup SimpleX + Enllaços SimpleX + Aquest xat està protegit per xifratge d\'extrem a extrem. + Aquest xat està protegit per un xifratge d\'extrem a extrem resistent a la quàntica. + format de missatge desconegut + mitjançant %1$s + Mitjançant navegador + mitjançant enllaç d\'adreça de contacte + mitjançant enllaç de grup + mitjançant enllaç d\'un sol ús + has compartit un enllaç d\'un sol ús + Nom mostrat no vàlid! + Assegureu-vos que les adreces del servidor SMP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + Assegureu-vos que les adreces del servidor XFTP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + No hi ha servidors multimèdia ni de fitxers. + No hi ha servidors de missatges. + No hi ha servidors per a l\'encaminament de missatges privats. + No hi ha servidors per rebre fitxers. + No hi ha servidors per rebre missatges. + No hi ha servidors per enviar fitxers. + Comproveu la vostra connexió de xarxa amb %1$s i torneu-ho a provar. + Si us plau, prova-ho més tard. + Actualitzeu l\'aplicació i contacteu amb els desenvolupadors. + Error d\'encaminament privat + L\'adreça del servidor és incompatible amb la configuració de xarxa: %1$s. + La versió del servidor és incompatible amb la vostra aplicació: %1$s. + Aquest nom mostrat no és vàlid. Si us plau, trieu-ne un altre. + Ja teniu un perfil de xat amb el mateix nom mostrat Si us plau, trieu un altre nom. + Enllaç de connexió no vàlid + El remitent ha cancel·lat la transferència de fitxers. + Servidors desconeguts! + Ja esteu connectat a %1$s. + Comproveu que heu utilitzat l\'enllaç correcte o demaneu al vostre contacte que us n\'enviï un altre. + És possible que el remitent hagi suprimit la sol·licitud de connexió. + La connexió ha arribat al límit de missatges no lliurats, és possible que el vostre contacte estigui fora de línia. + Missatges no lliurats + A menys que el vostre contacte hagi suprimit la connexió o aquest enllaç ja s\'ha utilitzat, pot ser que sigui un error; si us plau, informeu-ho.\nPer connectar-vos, demaneu al vostre contacte que creï un altre enllaç de connexió i comproveu que teniu una connexió de xarxa estable. + Possiblement, l\'empremta digital del certificat a l\'adreça del servidor és incorrecta + El servidor requereix autorització per crear cues, comproveu la contrasenya + El servidor requereix autorització per carregar, comproveu la contrasenya + La prova ha fallat al pas %s. + Cua segura + Carrega fitxer + Notificacions instantànies + Notificacions instantànies! + Les notificacions instantànies estan desactivades! + Notificacions periòdiques + Notificacions periòdiques desactivades + L\'aplicació obté missatges nous periòdicament: utilitza un percentatge de la bateria al dia. L\'aplicació no utilitza notificacions push: les dades del vostre dispositiu no s\'envien als servidors. + Obriu la configuració de l\'aplicació + Sense trucades en segon pla + És possible que l\'aplicació es tanqui al cap d\'un minut en segon pla. + Cal contrasenya + Per rebre notificacions introduïu la contrasenya de la base de dades + Rebent missatges… + Servei de SimpleX Xat + La base de dades no funciona correctament. Toca per obtenir més informació + Videotrucada + Servei de notificacions + Vista prèvia de la notificació + Vista prèvia + Cíclic + Amaga el contacte i el missatge + Text del missatge + Nova sol·licitud de contacte + missatge nou + Mostrar el contacte i el missatge + Mostrar només el contacte + Sense codi d\'accés de l\'aplicació + Camp de codi d\'accés + Bloqueig SimpleX + Mode de Bloqueig SimpleX + Autenticació del sistema + Per protegir la vostra informació activeu SimpleX Lock.\nSe us demanarà que completeu l\'autenticació abans que aquesta funció estigui habilitada. + Activar + No has pogut ser verificat; si us plau, torna-ho a provar. + Recordeu-la o emmagatzemeu-la de manera segura: no hi ha manera de recuperar una contrasenya perduda! + Bloqueig SimpleX habilitat + Se us demanarà que us autentiqueu quan inicieu o reinicieu l\'aplicació després de 30 segons en segon pla. + Inicieu sessió amb la vostra credencial + Avís d\'entrega de missatge + El més probable és que aquest contacte hagi suprimit la connexió amb tu. + Cap missatge + Obrir consola de xat + Obrir pantalla de migració + Bloqueig SimpleX no habilitat! + Aturar SimpleX + Podeu activar el Bloqueig SimpleX mitjançant Configuració. + Aquest missatge s\'ha suprimit o encara no s\'ha rebut. + Problemes de xarxa: el missatge ha caducat després de molts intents d\'enviar-lo. + Clau incorrecta o connexió desconeguda: el més probable és que aquesta connexió s\'hagi suprimit. + L\'adreça del servidor és incompatible amb la configuració de la xarxa. + La versió del servidor és incompatible amb la configuració de la xarxa. + Clau incorrecta o adreça de bloc de fitxer desconeguda: el més probable és que el fitxer s\'hagi suprimit. + Missatge rebut + Missatge enviat + Sense historial + Desat des de + El(s) destinatari(s) no veu(en) de qui és aquest missatge. + Sense informació de lliurament + El missatge se suprimirà; això no es pot desfer! + Els missatges es marcaran per eliminar-los. Els destinataris podran revelar aquests missatges. + Els missatges s\'eliminaran per a tots els membres. + El missatge s\'eliminarà per a tots els membres. + El missatge es marcarà com a moderat per a tots els membres. + S\'aturarà la recepció del fitxer. + L\'enviament del fitxer s\'aturarà. + Aturar fitxer + Deixar de rebre fitxer? + Deixar d\'enviar el fitxer? + Missatge reenviat + Encara no hi ha connexió directa, el missatge el reenvia l\'administrador. + Revocar fitxer + enviament no autoritzat + Aquest text està disponible a la configuració + Benvingut %1$s! + unir-te com a %s + enviar missatge directe + Toca per iniciar un xat nou + Carregant xats… + No hi ha xats filtrats + Cerqueu o enganxeu l\'enllaç SimpleX + Toqueu per connectar + No tens cap xat + No hi ha cap xat seleccionat + Res seleccionat + Seleccionats %d + Res a reenviar! + Els missatges s\'han suprimit després de seleccionar-los. + Compartir fitxer… + Compartir mitjans… + Compartir missatge… + Les preferències de xat seleccionades prohibeixen aquest missatge. + Massa imatges! + Massa vídeos! + Només es poden enviar 10 imatges al mateix temps + Només es poden enviar 10 vídeos al mateix temps + La imatge no es pot descodificar. Si us plau, proveu amb una imatge diferent o contacteu amb els desenvolupadors. + El vídeo no es pot descodificar. Si us plau, prova amb un vídeo diferent o contacta amb els desenvolupadors. + ets observador + No pots enviar missatges! + Poseu-vos en contacte amb l\'administrador del grup. + Només els propietaris del grup poden activar fitxers i mitjans. + Desant %1$s missatges + Envia missatge directe per connectar + Enllaços SimpleX no permesos. + Si us plau, redueix la mida del missatge i torna a enviar-lo. + Si us plau, reduïu la mida del missatge o elimineu mitjans i torneu a enviar-lo. + Missatges de veu no permesos + Podeu copiar i reduir la mida del missatge per enviar-lo. + Imatge + Imatge enviada + Esperant la imatge + Esperant la imatge + Imatge desada a la Galeria + La imatge es rebrà quan el vostre contacte acabi de carregar-la. + La imatge es rebrà quan el vostre contacte estigui en línia, espereu o comproveu més tard! + Esperant el vídeo + Arxiu gran! + Vídeo enviat + El vídeo es rebrà quan el contacte acabi de pujar-lo. + El vídeo es rebrà quan el contacte estigui en línia, espereu o comproveu més tard! + Esperant el vídeo + El vostre contacte ha enviat un fitxer més gran que la mida màxima admesa actualment (%1$s). + Carregant el fitxer + Notificacions + obrir + Si us plau, espereu mentre es carrega el fitxer des del mòbil enllaçat + Error de fitxer temporal + Missatge de veu + Missatge de veu… + Missatge de veu (%1$s ) + Esperant el fitxer + Només suprimeix la conversa + Pendente + L\'adreça de recepció es canviarà per un servidor diferent. El canvi d\'adreça es completarà quan el remitent estigui en línia. + Estableix el nom del contacte… + Pots enviar missatges a %1$s des dels contactes arxivats. + Encara podeu veure la conversa amb %1$s a la llista de xats. + El xifratge funciona i el nou acord de xifratge no és necessari. Pot resultar en errors de connexió! + Renegociar + Veure codi de seguretat + Comprovar codi de seguretat + Enviar Missatge + Grava el missatge de veu + Heu de permetre que el vostre contacte enviï missatges de veu per poder-los enviar. + Missatge en directe! + cap detall + OK + Enllaç d\'invitació d\'un sol ús + Només els propietaris de grups poden activar els missatges de veu. + (només emmagatzemat pels membres del grup) + Permís denegat! + Demaneu al vostre contacte que habiliti l\'enviament de missatges de veu. + Envia un missatge en directe: s\'actualitzarà per al(s) destinatari(s) a mesura que l\'escrius + Envia un missatge que desapareix + Envia missatge en directe + Toqueu per escanejar + (per compartir amb el teu contacte) + Missatges de veu prohibits! + Gràcies per instal·lar SimpleX Xat! + Per iniciar un xat nou + Toqueu el botó + Si decideixes rebutjar, el remitent NO rebrà notificació. + Si heu rebut l\'enllaç d\'invitació de SimpleX Chat, podeu obrir-lo al vostre navegador: + Rebutjar + Per connectar-se mitjançant enllaç + vista prèvia de l\'enllaç + Marcar com a llegit + imatge de perfil + espai per a la imatge de perfil + Codi QR + Estableix el nom del contacte + La connexió que heu acceptat es cancel·larà! + El contacte amb qui has compartit aquest enllaç NO es podrà connectar! + Desfavorit + vol contactar amb tu! + Has convidat un contacte + El vostre contacte ha d\'estar en línia perquè la connexió es completi.\nPots cancel·lar aquesta connexió i eliminar el contacte (i provar-ho més tard amb un enllaç nou). + Mostrar codi QR + Aquest no és un enllaç de connexió vàlid! + Aquest codi QR no és un enllaç! + Et connectaràs al grup quan el dispositiu de l\'amfitrió estigui en línia. Espereu o comproveu més tard! + Et connectaràs quan s\'accepti la teva sol·licitud de connexió, si us plau, espera o consulta més tard! + Si no pots trobar-te en persona, mostra el codi QR en una videotrucada o comparteix l\'enllaç. + Enganxeu l\'enllaç que heu rebut per connectar amb el vostre contacte… + Compartir enllaç d\'un sol ús + Compartir enllaç d\'un sol ús amb un amic + Compartir adreça públicament + Comparteix l\'adreça SimpleX a les xarxes socials. + Per connectar-se, el vostre contacte pot escanejar el codi QR o utilitzar l\'enllaç de l\'aplicació. + Per protegir-vos de la substitució del vostre enllaç, podeu comparar els codis de seguretat de contacte. + Quan algú sol·liciti la connexió, pots acceptar-la o rebutjar-la. + Podeu definir el nom de la connexió per recordar amb qui s\'ha compartit l\'enllaç. + Pots compartir la teva adreça com a enllaç o codi QR; qualsevol es pot connectar amb tu. + S\'enviarà el teu perfil de xat\nal teu contacte + El teu perfil %1$s es compartirà. + Et connectaràs quan el dispositiu del teu contacte estigui en línia, si us plau, espera o consulta més tard! + Si més tard decideixes eliminar la teva adreça els contactes no es perdran. + Mantenir la invitació no utilitzada? + Nou xat + Missatge nou + No hi ha contactes filtrats + Enllaç d\'invitació d\'un sol ús + O escaneja el codi QR + O mostra aquest codi + Enganxar enllaç + Enganxar l\'enllaç rebut + Seleccioneu el perfil de xat + Compartir perfil + Compartir aquest enllaç d\'un sol ús + Toca per enganxar l\'enllaç + El codi QR que heu escanejat no és un enllaç de SimpleX. + El text enganxat no és un enllaç SimpleX. + Aquesta cadena no és un enllaç de connexió! + Podeu tornar a veure l\'enllaç d\'invitació als detalls de connexió. + La teva connexió s\'ha mogut a %s, però s\'ha produït un error inesperat en redirigir-te al perfil. + Marcar com a verificat + Escaneja el codi de seguretat des de l\'aplicació del teu contacte. + Contactes + Com utilitzar-lo + Servidors de fitxers i mitjans + Servidors de missatges + Nou servidor + Altres servidors SMP + Altres servidors XFTP + Servidor preestablert + Adreça predeterminada del servidor + Preguntes i idees + Contacta via email + La prova del servidor ha fallat! + Bloqueig SimpleX + %s no està verificat + %s està verificat + Alguns servidors han fallat la prova: + Provar servidor + Provar servidors + Servidors per a noves connexions del vostre perfil de xat actual + Utilitzar per a noves connexions + Utilitzar servidor + Perfils de xat + El teu servidor + L\'adreça del teu servidor + Configuració + La teva adreça SimpleX + Com + Com utilitzar els vostres servidors + Servidors ICE (un per línia) + Instal·lar SimpleX Chat per al terminal + Assegureu-vos que les adreces del servidor WebRTC ICE estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + Valoreu l\'aplicació + Restablir totes les pistes + Els servidors WebRTC ICE desats s\'eliminaran. + Mostrar percentatge + Estrela a GitHub + Utilitzar servidors SimpleX Xat? + Usant servidors SimpleX Xat. + Servidors ICE + Servidors SMP + Servidors XFTP + Xarxa i servidors + Autenticació d\'intermediari + Servidor intermediari SOCKS + Configuració SOCKS + Utilitzar credencials aleatòries + Utilitzar servidor intermediari SOCKS + Si confirmeu, els servidors de missatgeria podran veure la vostra adreça IP i el vostre proveïdor, a quins servidors us esteu connectant. + Les noves credencials de SOCKS s\'utilitzaran cada vegada que inicieu l\'aplicació. + S\'utilitzaran noves credencials SOCKS per a cada servidor. + Es necessitaran hosts .onion per a la connexió.\nTingueu en compte: no us podreu connectar als servidors sense l\'adreça .onion. + S\'utilitzaran amfitrions .onion quan estiguin disponibles. + No es faran servir hosts .onion + port %d + Encaminament privat + Servidor + Aïllament de transport + Actualitzar el mode d\'aïllament de transport? + Utilitza credencials de servidors intermediari diferents per a cada connexió. + Utilitza credencials de servidor intermediari diferents per a cada perfil. + Utilitzar connexió a Internet directa? + Utilitzar servidors .onion + Utilitzar servidor intermediari SOCKS? + Si disponibles + Les vostres credencials es podrien enviar sense xifrar. + COLORS DE LA INTERFÍCIE + Alternativa d\'encaminament de missatges + Mode d\'encaminament de missatges + Obrir ubicació del fitxer + Envieu missatges directament quan l\'adreça IP està protegida i el vostre servidor de destinació no admet l\'encaminament privat. + Enviar missatges directament quan el vostre servidor de destinació no admet l\'encaminament privat. + Mostrar l\'estat del missatge + simplexmq: v%s (%2s) + Ometre aquesta versió + Per protegir la vostra adreça IP l\'encaminament privat utilitza els vostres servidors SMP per enviar missatges. + Servidors desconeguts + Actualització disponible: %s + Utilitzar l\'encaminament privat amb servidors desconeguts. + Utilitzeu l\'encaminament privat amb servidors desconeguts quan l\'adreça IP no estigui protegida. + Amb IP oculta + Descàrrega d\'actualització cancel·lada + Si us plau, reinicieu l\'aplicació. + Recorda més tard + Mostrar opcions de desenvolupador + Mostrar errors interns + Mostrar trucades lentes d\'API + Per rebre notificacions sobre les noves versions activeu la comprovació periòdica de les versions Estable o Beta. + Compartir amb contactes + La plataforma de missatgeria i aplicacions que protegeix la vostra privadesa i seguretat. + El perfil només es comparteix amb els teus contactes. + No emmagatzemem cap dels vostres contactes o missatges (un cop lliurats) als servidors. + El vostre perfil, contactes i missatges lliurats s\'emmagatzemen al vostre dispositiu. + Obrir configuració + El futur de la missatgeria + Operadors de xarxa + Notificacions i bateria + La contrasenya aleatòria s\'emmagatzema a la configuració com a text pla.\nPodeu canviar-ho més tard. + Seleccioneu els operadors de xarxa que voleu utilitzar. + Operadors de servidor + Configura la contrasenya de la base de dades + Utilitzar contrasenya aleatòria + Obrir SimpleX Chat per acceptar la trucada + El servidor de retransmissió protegeix la vostra adreça IP, però pot veure la durada de la trucada. + Servidors WebRTC ICE + Servidors ICE + Privacitat i seguretat + Protegeix la pantalla de l\'aplicació + Protegir l\'adreça IP + Privacitat + Si introduïu aquesta contrasenya en obrir l\'aplicació, totes les dades de l\'aplicació s\'eliminaran de manera irreversible. + Si introduïu el vostre codi d\'autodestrucció mentre obriu l\'aplicació: + Estableix codi + Aquesta configuració és per al vostre perfil actual + Es pot canviar a la configuració de contacte i grup. + No + CONFIGURACIÓ + Tou + Fort + SUPORT SIMPLEX XAT + Connexió a la xarxa + ENCAMINAMENT DE MISSATGES PRIVAT + mai + No s\'han rebut ni enviats fitxers + Reinicieu l\'aplicació per crear un perfil de xat nou. + Aquesta acció no es pot desfer: se suprimiran tots els fitxers i els mitjans rebuts i enviats. Les imatges de baixa resolució es mantindran. + Heu d\'utilitzar la versió més recent de la vostra base de dades de xat NOMÉS en un dispositiu, en cas contrari, podeu deixar de rebre els missatges d\'alguns contactes. + Aquesta configuració s\'aplica als missatges del vostre perfil de xat actual + La vostra base de dades de xat no està xifrada; definiu una contrasenya per protegir-la. + La frase de contrasenya s\'emmagatzemarà a la configuració com a text pla després de canviar-la o reiniciar l\'aplicació. + Contrasenya de la base de dades incorrecta + Heu d\'introduir la contrasenya cada vegada que s\'inicia l\'aplicació: no s\'emmagatzema al dispositiu. + Error en Keystore + Obrir xat + Si us plau, emmagatzemeu la contrasenya de manera segura, NO podreu accedir al xat si la perdeu. + Si us plau, emmagatzemeu la contrasenya de manera segura, NO la podreu canviar si la perdeu. + Confirmació de migració no vàlida + No s\'ha trobat la contrasenya a Keystore, introduïu-la manualment. Això pot haver passat si vau restaurar les dades de l\'aplicació mitjançant una eina de còpia de seguretat. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. + Barres d\'eines d\'aplicacions accessible + Barres d\'eines de xat accessible + Mostra la llista de xat en una finestra nova + Commuta la llista de xat: + Mostrar consola en finestra nova + Podeu canviar-la a la configuració de l\'aparença. + Actualitzar i obrir el xat + Toca per unir-te + Toca per unir-te d\'incògnit + T\'has unit a aquest grup + Has rebutjat la invitació del grup + Esteu utilitzant un perfil d\'incògnit en aquest grup; per evitar mostrar el vostre perfil principal no es permet convidar contactes + Has enviat la invitació del grup + convidat mitjançant l\'enllaç del vostre grup + ha actualitzat el perfil del grup + has canviat el rol de %s a %s + has blocat a %s + has canviat el teu rol a %s + has marxat + has foragitat a %1$s + has desblocat a %s + observador + xifratge extrem a extrem resistent quàntic + Rol inicial + Convidar a xatejar + Nou rol de membre + No hi ha contactes per afegir + Membre anterior %1$s + Ometre convidar membres + desconegut + Convidar membres + Cap contacte seleccionat + Esteu provant de convidar el contacte amb qui heu compartit un perfil d\'incògnit al grup en què feu servir el vostre perfil principal + Missatge de benvinguda + Només els propietaris del xat poden canviar les preferències. + Només els propietaris del grup poden canviar-ne les preferències. + Compartir adreça + Enviar rebuts + Enviat + Registre actualitzat + Moderat el + Registre actualitzat: %s + Enviat: %s + sense text + Eliminar membre? + %s: %s + Eliminar membre + Els missatges de %s es mostraran! + Demaneu al vostre contacte que habiliti les trucades. + Enviar un missatge per activar trucades. + Heu de permetre que el vostre contacte truqui per poder trucar-los. + Vista prèvia + Rebent via + Desa i actualitza el perfil del grup + SERVIDORS + Missatge de benvinguda + El missatge de benvinguda és massa llarg + Els teus servidors + Servidors preestablerts + Revisar condicions + Per a rebre + Utilitzar per a fitxers + Utilitzar per a missatges + Fes el perfil privat! + Permet tenir moltes connexions anònimes sense cap dada compartida entre elles en un únic perfil de xat. + El teu perfil aleatori + Mostrar perfil ocult + Quan comparteixes un perfil d\'incògnit amb algú, aquest perfil s\'utilitzarà per als grups als quals et conviden. + Tema + Importar tema + Error en importar tema + Assegureu-vos que el fitxer tingui la sintaxi YAML correcta. Exporta el tema per tenir un exemple de l\'estructura del fitxer del tema. + Missatge enviat + Resposta enviada + Títol + Missatge rebut + Resposta rebuda + Color imatge de fons + Color de fons + Transparència + Zoom + Enllaços SimpleX + Historial visible + desactivat + recepció no permesa + Establir 1 dia + Només tu pots enviar missatges que desapareixen. + Només el vostre contacte pot enviar missatges que desapareixen. + Prohibir l\'enviament de missatges de veu. + Només tu pots enviar missatges de veu. + Només el vostre contacte pot enviar missatges de veu. + Els missatges de veu estan prohibits en aquest xat. + Només tu pots afegir reaccions als missatges. + Només el vostre contacte pot afegir reaccions als missatges. + Les reaccions als missatges estan prohibides en aquest xat. + Només tu pots fer trucades. + Només el vostre contacte pot fer trucades. + Prohibir l\'enviament de missatges de veu. + Prohibir l\'enviament d\'enllaços SimpleX + Es prohibeix la supressió irreversible de missatges. + Els membres poden afegir reaccions als missatges. + Els membres poden suprimir de manera irreversible els missatges enviats. (24 hores) + Els membres poden enviar missatges que desapareixen. + Els membres poden enviar enllaços SimpleX. + Les reaccions als missatges estan prohibides. + Envia fins a 100 darrers missatges als nous membres. + Els missatges de veu estan prohibits. + propietaris + Múltiples perfils de xat + Gràcies als usuaris: contribuïu a través de Weblate! + Comprovar la seguretat de la connexió + Estableix el missatge que es mostra als nous membres! + Interfície d\'usuari en japonès i portuguès + Reaccions a missatges + Aviat hi haurà més millores! + Interfície en polonès + Codi d\'autodestrucció + Configureu-lo en lloc de l\'autenticació del sistema. + Gràcies als usuaris: contribuïu a través de Weblate! + Gràcies als usuaris: contribuïu a través de Weblate! + Gràcies als usuaris: contribuïu a través de Weblate! + Vídeos i fitxers de fins a 1 GB + - Missatges de veu fins a 5 minuts.\n- Temps personalitzat per a missatges temporals.\n- Historial d\'edició. + - Lliurament de missatges més estable.\n- Grups millorats.\n- I més! + Nova aplicació per a ordinador! + La doble comprovació que ens mancava! ✅ + Manté les vostres connexions + Fer desaparèixer un missatge + Unir-se a converses de grup + Historial recent i bot de directori millorat. + Canvia l\'àudio i el vídeo durant la trucada. + Canvia el perfil de xat per a invitacions d\'un sol ús. + Podeu activar-ho més tard mitjançant Configuració + Enllaçar un mòbil + Mòbils enllaçats + Nom d\'aquest dispositiu + Verificar codi al mòbil + Verificar connexió + El nom del dispositiu es compartirà amb el client mòbil connectat. + Aquest dispositiu + Opcions ordinador enllaçat + Cap mòbil connectat + No compatible! + Obrir port al tallafoc + Enganxar adreça d\'ordinador + Aleatori + Recarregar + Escaneja el codi QR des de l\'ordinador + Per permetre que una aplicació mòbil es connecti a l\'ordinador obriu aquest port al vostre tallafoc, si el teniu habilitat + Verificar connexions + Aquesta funció encara no està disponible. Prova la propera versió. + Aquest és el vostre enllaç d\'un sol ús! + Aquesta és la teva pròpia adreça SimpleX! + Confirmeu que la configuració de xarxa és correcta per a aquest dispositiu. + Servidors connectats prèviament + Servidors intermediats + Com funciona + IU en hongarès i turc + Codi d\'accés no canviat! + Protegiu la vostra adreça IP dels servidors de retransmissió de missatgeria escollits pels vostres contactes.\nActiva a la configuració de *Xarxa i servidors*. + Toqueu Crea adreça SimpleX al menú per crear-la més tard. + Per protegir la zona horària els fitxers d\'imatge/veu utilitzen UTC. + Desbloca + La càrrega ha fallat + Per verificar el xifratge d\'extrem a extrem amb el vostre contacte compareu (o escanegeu) el codi dels vostres dispositius. + L\'actualització de la configuració reconnectarà el client a tots els servidors. + Atenció: l\'inici del xat a diversos dispositius és incompatible i provocaria errors en el lliurament de missatges. + Canvia + Voleu desar les preferències? + Voleu deixar de compartir l\'adreça? + secret + Altaveu desactivat + Altaveu activat + Sense Tor o una VPN, el servidors de fitxers podran veure la vostra adreça. + Sistema + Autodestrucció + Codi d\'autodestrucció + Grups petits (màx. 20) + Alguns fitxers no han estat exportats + s + Mida + Voleu iniciar un xat? + Escala + + Selecciona + %s baixades + WiFi + Ethernet per cable + Estadistiques + Servidor XFTP + Avaluació de seguretat + Servidor SMP + segons + Inicia un xat + "Invitació d\'un sol ús per SimpleX" + Sense Tor o una VPN, la vostra adreça serà visible per als següents relays XFTP: %1$s. + Funció lenta + heu estat convidat a un grup + cerca + (escaneja o enganxa del porta-retalls) + Escaneja un codi QR + Comença una conversa nova + Logo de SimpleX + Equip SimpleX + Heu acceptat la connexió + Escaneja / Enganxa un enllaç + Escaneja codi + Codi de seguretat + Voleu desar els servidors? + Escaneja el codi QR del servidor + Servidors SMP + Servidors XFTP + Altaveu + VÓS + %s segon(s) + "Heu estat convidat a un grup" + %s connectat + codi de seguretat modificat + encriptació extrem a extrem estàndard + Selecciona contactes + vós : %1$s + %s (actual) + Voleu desar el missatge de benvinguda? + Sistema + Sistema + Sistema + Secundari + Protocols de SimpleX revisats per Trail of Bits. + Escaneja amb el mòbil + Vídeo + vídeo + Tema del perfil + Contrasenya del perfil + No es permeten trucades ni videotrucades. + No es permeten reaccions als missatges. + No es permet l\'enviament de missatges temporals. + No es permet l\'eliminació irreversible de missatges. + No es permeten reaccions als missatges. + No es permet l\'enviament de missatges directes als membres. + No es permet l\'enviament de missatges temporals. + L\'actualització del perfil s\'enviarà als vostres contactes. + a + b + ratllat + Suprimeix els missatges després + Eliminar el perfil de xat per + La connexió no està preparada. + Error en crear la llista de xat + Error en carregar llistes de xat + Error en actualitzar les llistes de xat + Contactes + Preferits + Cap xat no llegit + Afegir llista + Tot + Negocis + Obrir amb %s + Sense xats + No s\'han trobat xats + Canviar llista + Eliminar + Grups + No hi ha cap xat a la llista %s. + Llista + El nom de la llista i l\'emoji haurien de ser diferents per a totes les llistes. + Notes + Afegir a la llista + Crear llista + Desar llista + Tots els xats s\'eliminaran de la llista %s i aquesta serà suprimida + Canviar ordre + Editar + Eliminar llista? + Nom de la llista... + Spam + Arxivar + Arxivar informe + Informar de spam: només ho veurà la moderació del grup. + Informar del perfil d\'un/a membre: només ho veurà la moderació del grup. + Informar de violació: només ho veurà la moderació del grup. + Informar de contingut: només ho veurà la moderació del grup. + Informar d\'altres: només ho veurà la moderació del grup. + informe arxivat per %s + Un altre motiu + Violació de les normes de la comunitat + Contingut inadequat + Perfil inadequat + Suprimeix l\'informe + L\'informe s\'arxivarà. + Informar + Spam + El contingut infringeix les condicions d\'ús + Connexió blocada + La connexió està bloquejada per l\'operador del servidor:\n%1$s. + Arxivar informe? + El fitxer està blocat per l\'operador del servidor:\n%1$s. + Motiu de l\'informe? + 1 informe + %d informes + Informes de membres + Informes + Error en desar configuració + No + Obrir l\'enllaç web? + + Obrir enllaç + moderador + Preguntar + Obrir enllaços de la llista de xat + informes arxivats + Només ho veuen remitents i moderació + Només ho veieu vosaltres i moderació + Error en crear informe + Establir nom del xat… + Suprimiu els missatges de xat del vostre dispositiu. + Voleu canviar la supressió automàtica de missatges? + predeterminat (%s) + 1 any + Desactivar la supressió de missatges + Desactivar la supressió automàtica de missatges? + Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat anteriors al seleccionat se suprimiran. + Els missatges d\'aquest xat no se suprimiran mai. + Port TCP per a missatgeria + Emprar port web + Emprar el port TCP %1$s quan no se n\'especifica cap. + Silenciar tot + Mencions no llegides + Podeu mencionar fins a %1$s membres per missatge! + Permetre denunciar missatges a moderació. + No permetre denunciar missatges a moderació. + Arxivar totes les denúncies? + Arxivar %d denúncies? + Arxivar denúncies + Per a totes les moderadores + Per a mi + Denúncia: %s + En aquest grup no es permet denunciar missatges. + Els/les membres poden denunciar els missatges a moderació. + Totes les denúncies s\'arxivaran. + Mencionar els membres 👋 + Enviar denúncies privades + Millor privadesa i seguretat + No us perdeu missatges importants. + Enviament de missatges més ràpid. + Organitzar els xats en llistes + Noms de fitxers multimèdia privats. + Establir la caducitat del missatge als xats. + Rebeu notificació quan se us menciona. + Millor rendiment dels grups + Supressió més ràpida de grups. + Ajudar els administradors a moderar els seus grups. + rebutjat + rebutjat + pendent + pendent d\'aprovació + Tots els missatges nous d\'aquests/es membres s\'amagaran! + Blocar membres per a tots/es? + Condicions actualitzades + Error en llegir la contrasenya de la base de dades + Els/les membres s\'eliminaran del xat; això no es pot desfer! + Els missatges d\'aquests/es membres es mostraran! + Desblocar membres per a tots/es? + moderació + La frase de contrasenya a Keystore no es pot llegir. Això pot haver passat després que l\'actualització del sistema sigui incompatible amb l\'aplicació. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. + Els/les membres s\'eliminaran del grup; això no es pot desfer! + La frase de contrasenya a Keystore no es pot llegir, introduïu-la manualment. Això pot haver passat després que l\'actualització del sistema sigui incompatible amb l\'aplicació. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. + Expulsar membres? + Política de privadesa i condicions d\'ús. + Els xats privats, els grups i els vostres contactes no són accessibles per als operadors de servidor. + Acceptar + En utilitzar SimpleX Chat accepteu:\n- enviar només contingut legal en grups públics.\n- Respectar els altres usuaris, sense correu brossa. + Configurar els operadors de servidor + Enllaç al canal SimpleX + Aquest enllaç requereix una versió de l\'aplicació més recent. Actualitzeu l\'aplicació o demaneu al vostre contacte que enviï un enllaç compatible. + Enllaç de connexió no compatible + Emprar enllaços curts (BETA) + Enllaç complet + Enllaç curt + Tots els servidors + Apagat + Feu servir el port TCP 443 només per a servidors predefinits. + Servidors predefinits + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index c80d24f0bd..439b9df18d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -72,10 +72,10 @@ Nevratně mazat zprávy můžete pouze vy (váš kontakt je může označit ke smazání). (24 hodin) Nevratné mazání zpráv je v tomto chatu zakázáno. Přímé zprávy členům zakázány. - %d sec + %d s %ds %d min - %d hodinu + %d hodina nabízeno %s: %2s Odkazy na skupiny Hlasové zprávy @@ -208,7 +208,7 @@ Požadavek na připojení byl odeslán! Jednorázová pozvánka Bezpečnostní kód - %s je ověřeno + %s ověřen Chat konzole SMP servery Přednastavená adresa serveru @@ -243,7 +243,7 @@ Decentralizovaná Jak to funguje Jak funguje SimpleX - 2 vrstvého koncového šifrování.]]> + Pouze klientská zařízení ukládají uživatelské profily, kontakty, skupiny a zprávy. Soukromé oznámení Pravidelné Ignorovat @@ -320,7 +320,7 @@ Zabezpečit frontu Okamžitá oznámení! V nastavení ji lze vypnout - oznámení se budou zobrazovat pokud aplikace běží.]]> - povolte pro SimpleX běh na pozadí v dalším dialogu. Jinak budou oznámení vypnuta.]]> + Povolte v příštím dotazu okamžité přijímání notifikací.]]> Aplikace pravidelně načítá nové zprávy - denně spotřebuje několik procent baterie. Aplikace nepoužívá push oznámení - data ze zařízení nejsou odesílána na servery. Je vyžadována přístupová fráze Chcete-li dostávat oznámení, zadejte přístupovou frázi do databáze. @@ -343,8 +343,8 @@ volání… připojen ukončen - Příští generace soukromé komunikace - Lidé se s vámi mohou spojit pouze prostřednictvím odkazu, který sdílíte. + Budoucnost soukromé komunikace + Rozhodněte, kdo se může připojit. špatný kontrolní součet zprávy Databáze chatu importována Nová přístupová fráze… @@ -384,7 +384,7 @@ Smazat adresu Celé jméno: Váš současný profil - SimpleX službu na pozadí - denně využije několik procent baterie.]]> + SimpleX běží na pozadí místo používání oznámení.]]> Pravidelná oznámení SimpleX Chat služba Příjem zpráv… @@ -446,7 +446,7 @@ špatné ID zprávy duplicitní zpráva Přeskočené zprávy - Ochrana osobních údajů a zabezpečení + Soukromí a zabezpečení Vaše soukromí Skrývat aplikaci Odesílat náhledy odkazů @@ -468,7 +468,7 @@ Hlasové zprávy Vy i váš kontakt můžete nevratně mazat odeslané zprávy. (24 hodin) %dm - %dmth + %dm %d hodin %dh %dd @@ -574,7 +574,7 @@ Připojení prostřednictvím odkazu Pokud jste dostali SimpleX Chat pozvánku, můžete ji otevřít v prohlížeči: Pokud zvolíte odmítnutí, odesílatel NEBUDE upozorněn. - Otevřete v mobilní aplikaci, potom klikněte na Připojit.]]> + Otevřete v mobilní aplikaci, potom v aplikaci klepněte na Připojit.]]> Odmítnout Smazat chat Vyčistit @@ -604,7 +604,7 @@ Vložit Tento řetězec není odkazem na připojení! Otevřít v mobilní aplikaci.]]> - %s není ověřeno + %s neověřen Návod k použití Nápověda k markdown Uložit servery @@ -664,10 +664,10 @@ Nové vymezení soukromí Bez uživatelských identifikátorů Odolná vůči spamu - K ochraně soukromí, místo uživatelských ID užívaných všemi ostatními platformami, SimpleX používá identifikátory pro fronty zpráv, zvlášť pro každý z vašich kontaktů. + K ochraně soukromí, SimpleX používá ID pro každý z vašich kontaktů. úložišti GitHub.]]> Použijte chat - Lze změnit později v nastavení. + Jak ovlivňuje baterii Když aplikace běží Okamžité Nejlepší pro baterii. Budete přijímat oznámení pouze když aplikace běží (žádná služba na pozadí).]]> @@ -678,7 +678,7 @@ Příchozí zvukový hovor %1$s se s vámi chce spojit prostřednictvím videohovoru (nešifrovaného e2e). - zvukový hovor (nešifrováno e2e) + zvukový hovor (nešifrován e2e) Odmítnout Vaše hovory Spojení přes relé @@ -730,9 +730,9 @@ Odstranit všechny soubory Tuto akci nelze vrátit zpět - všechny přijaté a odeslané soubory a média budou smazány. Obrázky s nízkým rozlišením zůstanou zachovány. Žádné přijaté ani odeslané soubory - %d soubor(ů) s celkovou velikostí %s + %d soubor(y) s celkovou velikostí %s nikdy - %s vteřin(y) + %s sekund(y) Zprávy Toto nastavení se vztahuje na zprávy ve vašem aktuálním chat profilu. Smazat zprávy po @@ -766,7 +766,7 @@ Obnovit zálohu databáze\? Obnovit Chyba při obnovování databáze - Přístupová fráze nebyla v klíčence nalezena, zadejte jej prosím ručně. K této situaci mohlo dojít, pokud jste obnovili data aplikace pomocí zálohovacího nástroje. Pokud tomu tak není, obraťte se na vývojáře. + Přístupová fráze nebyla v klíčence nalezena, zadejte ji prosím ručně. K této situaci mohlo dojít, pokud jste obnovili data aplikace pomocí zálohovacího nástroje. Pokud tomu tak není, kontaktujte prosím vývojáře. Chat můžete spustit v Nastavení / Databáze nebo restartováním aplikace. pozvánka do skupiny %1$s Jste zváni do skupiny. Připojte se k členům skupiny. @@ -893,22 +893,22 @@ Posílání mizících zpráv zakázáno. Nevratné mazání odeslaných zpráv zakázáno. Hlasové zprávy zakázány. - Členové skupiny mohou posílat mizící zprávy. - Mizící zprávy jsou v této skupině zakázány. - Členové skupiny mohou posílat přímé zprávy. + Členové mohou posílat mizící zprávy. + Mizící zprávy jsou zakázány. + Členové mohou posílat přímé zprávy. Přímé zprávy mezi členy jsou v této skupině zakázány. - Členové skupin mohou nevratně mazat odeslané zprávy. (24 hodin) - Nevratné mazání zpráv je v této skupině zakázáno. - Členové skupiny mohou posílat hlasové zprávy. - Hlasové zprávy jsou v této skupině zakázány. + Členové mohou nevratně mazat odeslané zprávy. (24 hodin) + Nevratné mazání zpráv je zakázáno. + Členové mohou posílat hlasové zprávy. + Hlasové zprávy jsou zakázány. Smazat za %d měsíc %d měsíců %d den - %d dnů + %d dní %d týden %d týdnů - %dw + %dt nabízeno %s zrušeno %s Co je nového @@ -1038,7 +1038,7 @@ Bez hesla aplikace Nemohli jste být ověřeni; Zkuste to prosím znovu. %d minut - %d vteřin + %d sekund Ihned Zapamatujte si jej nebo bezpečně uložte - neexistuje způsob, jak obnovit ztracené heslo! Zámek SimpleX není povolen! @@ -1063,7 +1063,7 @@ Chyba dešifrování Špatný hash zprávy Špatné ID zprávy - Hash předchozí zprávy se liší. + Kontrolní součet předchozí zprávy se liší. ID další zprávy je nesprávné (menší nebo rovno předchozí). \nMůže se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitováno. %1$d zpráv se nepodařilo dešifrovat. @@ -1114,7 +1114,7 @@ Chyba načítání podrobností Info Hledat - Otevřít chat profily + Změnit chat profily Historie Přijatá zpráva Poslaná zpráva @@ -1141,7 +1141,7 @@ Posláno: %s Zmizí: %s Upraveno: %s - %s (aktuální) + %s (aktuálně) Tmavý motiv Import motivu SimpleX @@ -1154,8 +1154,8 @@ Reakce na zprávy jsou v tomto chatu zakázány. Zakázat reakce na zprávy. Zakázat reakce na zprávy. - Členové skupin mohou přidávat reakce na zprávy. - Reakce na zprávy jsou v této skupině zakázány. + Členové mohou přidávat reakce na zprávy. + Reakce na zprávy jsou zakázány. měsíců Zjistit více Sdílet s kontakty @@ -1192,9 +1192,7 @@ minut vteřin Díky uživatelům - překládejte prostřednictvím Weblate! - - 5 minutové hlasové zprávy. -\n- vlastní čas mizení. -\n- historie úprav. + - 5 minutové hlasové zprávy.\n- volitelný čas mizení zpráv.\n- historie úprav. dní hodin týdnů @@ -1228,7 +1226,7 @@ Pokud později adresu odstraníte, o kontakty nepřijdete. Přístupový kód aplikace je nahrazen sebedestrukčním přístupovým heslem. žádný text - Během importu došlo k nezávažným chybám - podrobnosti naleznete v chat konzoli. + Během importu došlo k nezávažným chybám: Vypnout\? Oznámení přestanou fungovat až do nového spuštění aplikace Vypnout @@ -1246,9 +1244,7 @@ Opravit nepodporované členem skupiny Opravit nepodporované kontaktem Zachovat vaše připojení - - více stabilní doručovaní zpráv. -\n- o trochu lepší skupiny. -\n- a více! + - stabilnější doručování zpráv.\n- trochu lepší skupiny.\n- a více! šifrování povoleno pro %s vyžadováno opětovné vyjednávání šifrování pro %s Odesílání potvrzení o doručení je vypnuto pro %d kontakty. @@ -1267,9 +1263,9 @@ Odoblíbit Přerušit změnu adresy Povolit odesílání souborů a médii. - Členové skupiny mohou posílat soubory a média. + Členové mohou posílat soubory a média. Pouze majitelé skupiny mohou povolit soubory a média. - Soubory a média jsou zakázány v této skupině. + Soubory a média jsou zakázány. Vypnout (zachovat přepsání) Povolit pro všechny Povolit (zachovat přepisování) @@ -1345,7 +1341,7 @@ %s: %s %s a %s připojen %s, %s a %s připojen - %s, %s a %d další členové připojeni + %s, %s a %d jiných členů připojeno Rozepsáno Zobrazit poslední zprávy Tato skupina má více než %1$d členů, doručenky nejsou odeslány. @@ -1377,9 +1373,7 @@ Zjednodušený režim inkognito Vytvořit nový profil v desktopové aplikaci. 💻 Změnit inkognito při připojování. - - připojit k adresáři skupin (BETA)! -\n- doručenky (až 20 členů). -\n- rychlejší a stabilnější. + - připojení k adresáři skupin (BETA)!\n- doručenky (až 20 členů).\n- rychlejší a stabilnější. odeslat přímou zprávu smazaný kontakt Chyba @@ -1390,7 +1384,7 @@ Adresa počítače Připojit se do skupiny? Skupina již existuje! - %s připojený + %s připojen Toto zařízení Počítač Jméno tohoto zařízení @@ -1429,8 +1423,8 @@ %1$s.]]> Propojit mobilní a stolní aplikace! 🔗 To je váš vlastní jednorázový odkaz! - %d zpráv označeno jako smazaná - Prostřednictvím zabezpečeného kvant rezistentního protokolu. + %d zpráv označeno jako smazané + Díky kvantům odolnému protokolu zabezpečení. Použijte z PC v aplikaci telefonu a naskenujte QR kód.]]> Již připojuji! Skrýt nežádoucí zprávy. @@ -1472,7 +1466,7 @@ Připojit s %1$s? Blokovat Odblokovat člena? - %d zpráv zablokováno + %d zpráv blokováno Chcete-li povolit telefonu připojení k PC, otevřete tento port ve vašem firewallu, pokud je povolen Blokovat člena Použít z PC @@ -1489,18 +1483,16 @@ Ověřit kód v telefonu Otevřít port ve firewallu Zadejte jméno tohoto zařízení… - Sdílíte neplatnou cestu souboru. Nahlaste problém vývojářům aplikace. + Sdíleli jste neplatnou cestu souboru. Nahlaste problém vývojářům aplikace. Odpojit mobilní telefony Blokovat člena? - %d skupinových událostí + %d událostí skupiny %1$s!]]> Ověřit kód s PC Skenovat QR kód z PC Odblokovat Objevitelný přes lokální síť - - volitelně oznámení odstraněným kontaktům. -\n- profilová jména s mezeramy. -\n- a více! + - volitelné oznámení odstraněným kontaktům.\n- profilová jména s mezeramy.\n- a více! Neplatná cesta souboru Již jste požádali o spojení přes tuto adresu! Zobrazit konzoli v novém okně @@ -1543,7 +1535,7 @@ Provedení funkce trvá příliš dlouho: %1$d vteřin: %2$s Připojit se ke skupině blokováno adminem - %d zpráv zablokováno správcem + %d zpráv blokováno správcem Chyba vytváření zprávy Chyba odstranění soukromých poznámek Smazat soukromé poznámky? @@ -1735,7 +1727,7 @@ Souběžné přijímání SimpleX odkazy Povoleno pro - Členové skupiny mohou odesílat SimpleX odkazy. + Členové mohou odesílat SimpleX odkazy. Zvuky v hovoru Zdroje zpráv zůstávají důvěrné. Správa sítě @@ -1751,7 +1743,7 @@ Kvantům odolné šifrování Kamera Kamera a mikrofon - SimpleX odkazy jsou v této skupině zakázány. + SimpleX odkazy jsou zakázány. koncovým šifrováním s dokonalým dopředným utajením, odmítnutím a obnovením po vloupání.]]> Pokročilé nastavení Všechny barevné režimy @@ -1860,7 +1852,7 @@ Nastavené SMP servery Probíhá Části nahrány - %1$d chuba souboru(ů):\n%2$s + %1$d chyba souboru:\n%2$s %1$d jiná chyba souboru(ů). Chyba přeposílaní zpráv Adresa serveru není kompatibilní s nastavením sítě. @@ -1940,7 +1932,7 @@ Nové přihlašovací údaje SOCKS budou použity pro každý server. Znovu připojte všechny připojené servery pro vynucení doručení. Využívá další provoz. Resetovat všechny tipy - %1$d soubor(y) stále stahuji. + %1$d soubor(y) se stále stahují. Lepší datování zpráv. Lepší zabezpečení ✅ Části odstraněny @@ -2014,7 +2006,7 @@ Odeslané zprávy Odeslaných celkem Adresa serveru - Dosažitelný panel nástrojů chatu + Dosažitelné panely nástrojů Chyba potvrzení Připojení Vytvořen @@ -2041,4 +2033,351 @@ Pošlete zprávu pro povolení volání. Přijmout podmínky Přijaté podmínky - \ No newline at end of file + Přidat seznam + Vidí to pouze odesílatelé a moderátoři + Vidite to pouze vy a moderátor + archivovaná hlášení + Chyba v konfiguraci serverů. + Chyba při příjímání podmínek + Žádné servery pro odesílání souborů. + Pokračovat + Otevřít změny + Nenalezen žádný chat + archivovaná hlášení podle %s + přijmuté pozvání + Porušení pokynů komunity + Nevhodný obsah + Jiný důvod + Chyba změny serverů + Nevhodný profil + Pro profil chatu %s: + Žádné chatovací servery. + Žádné servery pro soukromé směrování chatů. + Žádné servery pro příjem souborů. + Žádné servery pro příjem zpráv. + Všechny chaty budou ze seznamu odebrány %s, a seznam bude smazán + Pro sociální sítě + Vzdálené telefony + %s.]]> + %s.]]> + %s.]]> + Pro soukromé směrování + Otevřít podmínky + Nebo importovat soubor archivu + Obsah porušuje podmínky používání + Spojení blokováno + Připojení je blokováno serverovým operátorem:\n%1$s. + Archivovat hlášení? + Žádná zpráva + Archivovat + Archivovat hlášení + Vymazat hlášení + Soubor je blokován operátorem serveru:\n%1$s. + Nahlásit + Žádné nepřečtené chaty + Žádné chaty + Žádné chaty v seznamu %s. + Kontakty + Oblíbené + Vše + Nahlásit obsah: uvidí ho pouze moderátoři skupiny. + Otevřít pomocí %s + Vytvořit seznam + Přidat prátele + Ztlumit vše + Název seznamu a emoji by mělo být různé pro všechny seznamy. + Změnit seznam + Název seznamu... + Povolit logování + Síťoví operátoři + Otevřít odkaz ze seznamu chatu + Otevřít webový odkaz? + Zeptat se + Ne + Otevřít odkaz + Chyba ukládání databáze + Přidat členy týmu + Chat bude smazán pro všechny členy - tato akce je nevratná! + Chat bude pro vás smazán - tato akce je nevratná! + Opustit chat + Člen bude odstraněn z chatu - tato akce je nevratná! + Připojení není připraveno. + Podmínky budou automaticky přijaty pro povolené operátory dne: %s. + Síťový operátor + %s.]]> + Přednastavené servery + Text aktuálních podmínek se nepodařilo načíst, podmínky si můžete prohlédnout prostřednictvím tohoto odkazu: + Podmínky budou přijaty dne: %s. + %s.]]> + %s.]]> + Přidány servery pro média & soubory + Povolte Flux v nastavení sítě a serverů pro lepší ochranu metadat. + Servery přes proxy + Soukromí pro vaše zákazníky. + moderátor + Přidány chatovací servery + Operátor + Zakázat automatické mazání zpráv? + Vymazat zprávy chatu z tohoto zařízení. + Zakázat mazání zpráv + %s, přijměte podmínky používání.]]> + Xiaomi zařízení: aby fungovaly notifikace, povolte prosím Autostart v systémovém nastavení.]]> + Zpráva je moc velká! + Zmenšete prosím velikost zprávy a odešlete ji znovu. + Zmenšete prosím velikost zprávy nebo odeberte média a odešlete ji znovu. + pouze s jedním kontaktem - sdílejte osobně nebo přes jakoukoliv chatovací službu.]]> + Zabezpečení připojení + Aplikace vždy poběží na pozadí + Upozornění a baterie + Například, pokud váš kontakt dostane zprávu pres Chat server SimpleX, vaše aplikace ji doručí pomocí Flux serveru. + Chyba načtení seznamů chatu + Chyba vytváření seznamu chatu + Chyba aktualizace seznamu chatů + Firmy + Dosažitelný panel nástrojů chatu + Pozvat do chatu + Pouze vlastníci chatu mohou upravit předvolby. + Chat + Podmínky použití + Chyba přidání serveru + Panel nástrojů aplikace + Rozmazání + Přímé zprávy mezi členy jsou zakázány. + pro lepší ochranu metadat. + Chat již existuje + Přes proxy + Firemní chaty + Vylepšená navigace chatu + - Otevřít chat na první nepřečtené zprávě.\n- Přejít na citované zprávy. + Archivovat všechna hlášení? + Archivovat %d hlášení? + Pro všechny moderátory + Pro mě + %d reportů + Skupiny + Seznam + Hlášení členů + Poznámky + Zprávy v tomto chatu nebudou nikdy smazány. + Změnit řazení + Vymazat + Vymazat seznam? + Upravit + Chyba ukládání nastavení + Nebo sdílet soukromě + Firemní adresa + Podmínky přijaté dne: %s. + Přímé zprávy mezi členy jsou v tomto chatu zakázány. + Členové mohou zprávy nahlásit moderátorům. + Zakázat nahlašování zpráv moderátorům. + Chyba aktualizace serveru + Operátor serveru + Decentralizace sítě + Archivovat hlášení + Opustit chat? + Nastavení adres + Adresa nebo jednorázový odkaz? + Přidat na seznam + Přidat členy týmu ke konverzaci + Všechna hlášení vám budou archivována + Povolit nahlašování zpráv moderátorům + Změnit automatické mazání zpráv? + Vytvořit jednorázový odkaz + Jak to pomáhá soukromí + Vymazat chat + Vymazat chat? + %s.]]> + %s.]]> + %1$s.]]> + Relace aplikace + Podmínky budou přijaty pro povolené operátory po 30 dnech. + Připojení vyžaduje opětovné vyjednání šifrování. + Probíhá opětovné vyjednávání o šifrování. + Opravit + Opravit připojení? + Nový server + koncovým šifrováním, s post-quantovým zabezpečením v přímých zprávách.]]> + Žádné služba na pozadí + Kontrolovat zprávy každých 10 minut + Chyba vytváření hlášení + 1 rok + výchozí (%s) + Použijte náhodné přihlašovací údaje + Nahlásit spam: uvidí pouze skupinový moderátoři. + Přečíst podmínky + Webové stránky + Odebírán + požádáno o připojení + Spam + Žádné mediální a souborové servery. + Chyba dočasného souboru + Přesunout sezení + TCP připojení + Použité servery + Použit %s + Pro příjem + Systém + Spam + Nedoručené zprávy + Tato zpráva byla smazána, nebo dosud nebyla přijata. + Důvod nahlášení? + Report bude archivován. + Zprávy budou pro všechny členy označeny jako moderované. + Reporty + Nahlašte profil člena: uvidí pouze skupinový moderátoři. + Nahlásit porušení: Uvidí pouze moderátoři skupiny. + Zobrazit stav zpráv + Přeskočit tuto verzi + Zobrazit seznam v novém okně + Aktualizovat aplikaci automaticky + Některé soubory nebyly exportovány + Nepřečtené zmínky + Sdílet adresu veřejně + Sdílent SimpleX adresu na sociálních médiích. + Sdílejte 1 rázový odkaz s přítelem + SOCKS Proxy + Dostupná aktualizace: %s + Stabilní + Můžete nastavit operátory v nastavení sítě a serverů. + Ocas + Zastavíte přijímání zpráv z tohoto chatu. Chat historie bude zachována. + Servery pro nové soubory vašeho aktuálního chat profilu + Protokolu serveru se změnil. + Operátor serveru se změnil. + Zoom + Nastavit výchozí téma + Nahráno + Ano + Přepnout chat seznam: + Tuto akci nelze zrušit - zprávy odeslané a přijaté v tomto chatu dříve než vybraná, budou smazány. + Statistiky serverů budou obnoveny - nemůže být vráceno! + Odešlete soukromý report + Pomozte administrátorům moderovat své skupiny. + Rychlejší mazání skupin. + Od %s. + Můžete zmínit až %1$s členů ve zprávě! + Musíte kopírovat a snížit velikost zprávy, abyste ji poslali. + Jméno + SimpleX adresa nebo 1 rázový odkaz? + Nastavení + Uložit seznam + Stažení aktualizace zrušeno + Můžete uložit exportovaný archiv. + Použit pro zprávy + Server přidán k operátoru %s. + Průhlednost + Přepínání chat profilu pro 1-rázové pozvánky. + video + Sdílet profil + Reportování zpráv je zakázáno v této skupině. + XFTP servr + Nahrané soubory + Odběr ignorován + Verze serveru není kompatibilní s nastavením sítě. + Pro pozdější vytvoření adresy, klepněte v menu na Vytvořit SimpleX adresu. + Vaše servery + Zobrazit aktualizované podmínky + 1 report + zamítnuto + Report: %s + Nahlásit další: uvidí pouze skupinový moderátoři. + Můžete nastavit název připojení, pro pamatování, s kým byl odkaz sdílen. + Vaše připojení bylo přesunuto na %s, ale došlo k neočekávané chybě při přesměrování na profil. + Pro každý profil použijte různé přihlašovací údaje + Neznámé servery + Pro ochranu vaší IP adresy, soukromé směrování používá vaše servery SMP k doručování zpráv. + Použít web portu + TCP port pro zprávy + Váš chat profil bude zaslán členům + Režim systému + Zmínky členů 👋 + Organizujte konverzace do seznamů + Lepší výkon skupin + Lepší soukromí a bezpečnost + Nenechte si ujít důležité zprávy. + Rychlejší odesílání zpráv. + Vlastní názvy souborů médií. + Zobrazuji informace pro + Chyby nahrávání + Soukromé směrování se používá k doručování zpráv těmto serverům, protože k nim nejste připojeni. + Velikost + Přečíst později + Aktualizovat + %s servery + Celkem + Verze serveru není kompatibilní s vaší aplikací: %1$s. + Připojení dosáhlo limitu nedoručených zpráv, váš kontakt je asi offline. + Pro odeslílání + Použit pro soubory + Druhý přednastavený operátor v aplikaci! + Chcete-li volat, povolte použití mikrofonu. Ukončete hovor a zkuste to znovu. + Statistiky + Chyby odběru + O operátorech + SimpleX Chat a Flux udělali dohodu, a zahrnuly servery spravované Flux do aplikace. + SMP server + Vaše kontakty + Od %s.\nJsou všechna data uchovávána ve vašem zařízení. + Serverový operátoři + Vybrat provozovatele sítě pro použití. + Můžete nakonfigurovat servery v nastavení. + Zvuk ztlumen + Používat aplikaci jednou rukou. + SinpleX protokoly přezkoumány Trail Bits. + Přepnínání zvuku a videa během hovoru. + Vaše přihlašovací údaje mohou být zaslány nešifrované. + Pokud je povolen více než jeden operátor, nikdo z nich nemá metadata, aby poznal, kdo s kým komunikuje. + Nastavit název chatu.. + Použít TCP port %1$s, když není zadán žádný port. + Tento odkaz byl použit s jiným mobilním zařízením, vytvořte na počítači nový odkaz. + Získejte upozornění, když jste zmíněni. + SimpleX adresa a 1 rázové odkazy je bezpečné sdílet přes všechny komunikátory. + Zprávy budou smazány pro všechny členy. + Aplikace vyžaduje potvrzení stahování z neznámých serverů (s výjimkou .onion nebo při aktivaci SOCKS proxy). + Musíte povolit kontaktům volání, abyste jim mohli zavolat. + Nastavení expirace zpráv. + Zobrazit procenta + Nahraný archiv databáze bude ze serverů trvale odstraněn. + Pro ochranu před záměnou odkazů, můžete porovnat bezpečnostní kódy. + Stále si můžete prohlédnout rozhovor s %1$s v chat seznamu. + Aplikace chrání vaše soukromí pomocí různých operátorů v každé konverzaci. + Můžete jej změnit v nastavení Vzhledu. + Role se změní na %s. Každý v chatu bude upozorněn. + Chcete-li být informováni o nových verzích, zapněte periodickou kontrolu pro Stabilní nebo Beta verze. + Pro každé připojení použijte různé přihlašovací údaje. + Silný + Zobrazit podmínky + Můžete posílat zprávy %1$s z archivovaných kontaktů. + Můžete přenést exportovanou databázi. + zamítnuto + Jemný + Chyba čtení přístupové fráze databáze + Přístupová fráze v úlozišti klíčů nelze načíst, prosím zadejte ji ručně. To se může stát po aktualizaci systému nekompatibilní s aplikací. Pokud to tak není, kontaktujte prosím vývojáře. + Přístupovou frázi v ůložišti klíčů nelze načíst. Mohlo se to stát po aktualizaci systému nekompatibilní s aplikací. Pokud to není váš případ, kontaktujte prosím vývojáře. + Aktualizované podmínky + čekám na schválení + čekám + Blokovat členy všem? + Všechny nové zprávy od těchto členů budou skryty! + Odblokovat členy všem? + moderátoři + Zprávy od těchto členů budou zobrazeny! + Členové budou odstraněny ze skupiny - toto nelze zvrátit! + Odebrat členy? + Členové budou odstraněny z chatu - toto nelze zvrátit! + Použitím SimpleX chatu souhlasíte že:\n- ve veřejných skupinách budete zasílat pouze legální obsah.\n- budete respektovat ostatní uživatele – žádný spam. + Přijmout + Nastavit operátora serveru + Zásady ochrany soukromí a podmínky používání. + Soukromé konverzace, skupiny a kontakty nejsou přístupné provozovatelům serverů. + Nepodporovaný odkaz k připojení + Používejte krátké odkazy (BETA) + Tento odkaz vyžaduje novější verzi aplikace. Prosím aktualizujte aplikaci nebo požádejte kontakt o odeslání kompatibilního odkazu. + odkaz SimpleX kanálu + Úplný odkaz + Krátký odkaz + Všechny servery + Vypnut + Přednastavené servery + Použít TCP port 443 jen pro přednastavené servery. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index be6896d932..53f31ecba9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -20,7 +20,7 @@ Gelöscht als gelöscht markiert Das Senden von Dateien wird noch nicht unterstützt - Der Empfang von Dateien wird noch nicht unterstützt + Das Herunterladen von Dateien wird noch nicht unterstützt Profil Unbekanntes Nachrichtenformat Ungültiges Nachrichtenformat @@ -58,9 +58,9 @@ Fehler beim Senden der Nachricht Fehler beim Hinzufügen von Mitgliedern Fehler beim Beitritt zur Gruppe - Datei kann nicht empfangen werden + Datei kann nicht heruntergeladen werden Der Absender hat die Dateiübertragung abgebrochen. - Fehler beim Empfangen der Datei + Fehler beim Herunterladen der Datei Fehler beim Erstellen der Adresse Kontakt besteht bereits Sie sind bereits mit %1$s verbunden. @@ -195,10 +195,10 @@ Bild Warten auf ein Bild - Es wird um den Empfang eines Bildes gebeten + Es wird um das Herunterladen eines Bildes gebeten Bild gesendet Warten auf ein Bild - Das Bild wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! + Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! Bild wurde im Fotoalbum gespeichert Datei @@ -206,7 +206,7 @@ Ihr Kontakt hat eine Datei gesendet, die größer ist als die derzeit unterstützte maximale Größe (%1$s). Die derzeit maximal unterstützte Dateigröße beträgt %1$s. Warte auf Datei - Die Datei wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später noch mal nach! + Die Datei wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später noch mal nach! Datei gespeichert Datei nicht gefunden Fehler beim Speichern der Datei @@ -355,8 +355,8 @@ Chat-Konsole SMP-Server Voreingestellte Serveradresse - Füge voreingestellte Server hinzu - Füge Server hinzu + Voreingestellte Server hinzufügen + Server hinzufügen Teste Server Teste alle Server Alle Server speichern @@ -544,7 +544,7 @@ \n3. Die Verbindung wurde kompromittiert. Datenschutz & Sicherheit - Ihre Privatsphäre + Privatsphäre App-Bildschirm schützen Bilder automatisch akzeptieren Link-Vorschau senden @@ -585,30 +585,29 @@ Fehler beim Beenden des Chats Fehler beim Exportieren der Chat-Datenbank Chat-Datenbank importieren? - Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die importierte ERSETZT. -\nDiese Aktion kann nicht rückgängig gemacht werden! Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die importierte ERSETZT.\nIhr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! Importieren Fehler beim Löschen der Chat-Datenbank Fehler beim Importieren der Chat-Datenbank Chat-Datenbank importiert Starten Sie die App neu, um die importierte Chat-Datenbank zu verwenden. Chat-Profil löschen? - Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! Chat-Datenbank gelöscht Starten Sie die App neu, um ein neues Chat-Profil zu erstellen. Sie dürfen die neueste Version Ihrer Chat-Datenbank NUR auf einem Gerät verwenden, andernfalls erhalten Sie möglicherweise keine Nachrichten mehr von einigen Ihrer Kontakte. Dateien und Medien löschen? - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. - Keine empfangenen oder gesendeten Dateien + Es werden alle herunter- und hochgeladenen Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! + Keine herunter- oder hochgeladenen Dateien %d Datei(en) mit einem Gesamtspeicherverbrauch von %s nie - täglich - wöchentlich - monatlich + Älter als ein Tag + Älter als eine Woche + Älter als ein Monat %s Sekunde(n) - Löschen der Nachrichten + Nachrichten löschen Automatisches Löschen von Nachrichten aktivieren? - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. + Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden! Nachrichten löschen Fehler beim Ändern der Einstellung @@ -662,7 +661,7 @@ Bitte geben Sie das vorherige Passwort ein, nachdem Sie die Datenbanksicherung wiederhergestellt haben. Diese Aktion kann nicht rückgängig gemacht werden! Wiederherstellen Fehler bei der Wiederherstellung der Datenbank - Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Das kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Bitte nehmen Sie Kontakt mit den Entwicklern auf, wenn das nicht der Fall ist. + Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Dies kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Wenden Sie sich bitte an die Entwickler, wenn dies nicht der Fall ist. Der Chat wurde beendet Sie können den Chat über die App-Einstellungen/Datenbank oder durch Neustart der App starten. @@ -842,7 +841,7 @@ Chat-Präferenzen Kontakt-Präferenzen Gruppen-Präferenzen - Gruppen-Präferenzen einstellen + Gruppen-Präferenzen festlegen Ihre Präferenzen Direkte Nachrichten Für jeden löschen @@ -913,8 +912,8 @@ Periodisch Erlauben Sie das Senden von verschwindenden Nachrichten. In diesem Chat sind verschwindende Nachrichten nicht erlaubt. - Nur Sie können verschwindende Nachrichten senden. - Nur Ihr Kontakt kann verschwindende Nachrichten senden. + Nur Sie können verschwindende Nachrichten versenden. + Nur Ihr Kontakt kann verschwindende Nachrichten versenden. Fehler beim Laden des Chats Fehler beim Laden der Chats Bitte aktualisieren Sie die App und nehmen Sie Kontakt mit den Entwicklern auf. @@ -923,8 +922,8 @@ Erlauben Sie Ihren Kontakten das Senden von verschwindenden Nachrichten. Das Senden von verschwindenden Nachrichten nicht erlauben. Verschwindende Nachrichten sind nicht erlaubt. - Mitglieder können verschwindende Nachrichten senden. - Fügen Sie Server durch Scannen der QR-Codes hinzu. + Mitglieder können verschwindende Nachrichten versenden. + Server durch Scannen von QR-Codes hinzufügen. Verschwindende Nachrichten Übernehmen Einen Tag festlegen @@ -937,7 +936,7 @@ Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen. Private Benachrichtigungen Chat verwenden - Ihr Kontakt und Sie können beide verschwindende Nachrichten senden. + Ihr Kontakt und Sie können beide verschwindende Nachrichten versenden. %dh Gruppen-Links Neu in %s @@ -1015,7 +1014,7 @@ Moderieren Diese Nachricht wird für alle Mitglieder als moderiert gekennzeichnet. Sie sind Beobachter - Sie können keine Nachrichten versenden! + Sie sind Beobachter Beobachter Anfängliche Rolle Nachricht des Mitglieds löschen\? @@ -1078,22 +1077,22 @@ Die Datenbank-Version ist neuer als die App, keine Abwärts-Migration für: %s Verberge: Migrationen: %s - Das Bild wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. - Die Datei wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Das Bild wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Die Datei wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. Chat-Profil löschen Profil löschen Verbergen des Profils aufheben Passwort für Profil Verbergen des Chat-Profils aufheben - Aufforderung zum Empfang des Videos + Aufforderung zum Herunterladen des Videos Es können nur 10 Videos zur gleichen Zeit versendet werden Zu viele Videos auf einmal! Video Video gesendet - Das Video wird empfangen, sobald Ihr Kontakt das Hochladen beendet hat. + Das Video wird heruntergeladen, sobald Ihr Kontakt das Hochladen beendet hat. Auf das Video warten Auf das Video warten - Das Video wird empfangen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! + Das Video wird heruntergeladen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! Ihre XFTP-Server Host Fehler beim Speichern der XFTP-Server @@ -1160,11 +1159,11 @@ Sowohl Sie als auch Ihr Kontakt können Anrufe tätigen. Nur Sie können Anrufe tätigen. Audio-/Video-Anrufe nicht erlauben. - Den Empfang der Datei beenden\? - Das Senden der Datei beenden\? - Der Empfang der Datei wird beendet. + Herunterladen der Datei beenden? + Das Hochladen der Datei beenden? + Das Herunterladen der Datei wird beendet. Das Senden der Datei wird beendet. - Datei beenden + Download beenden Die Datei wird von den Servern gelöscht. Widerrufen Datei widerrufen @@ -1247,7 +1246,7 @@ Selbstzerstörung aktivieren Wenn Sie diesen Zugangscode während des Öffnens der App eingeben, werden alle App-Daten unwiederbringlich gelöscht! Selbstzerstörungs-Zugangscode - Zugangscode einstellen + Zugangscode festlegen Reaktionen auf Nachrichten sind nicht erlaubt. Fehler beim Laden von Details Empfangene Nachricht @@ -1305,7 +1304,7 @@ Gelöscht um: %s Verschwindet um Verschwindet um: %s - Nachrichtenverlauf bearbeiten + Nachrichtenverlauf In diesem Chat sind Reaktionen auf Nachrichten nicht erlaubt. Kein Text Während des Imports sind nicht schwerwiegende Fehler aufgetreten: @@ -1323,7 +1322,7 @@ Wechsel der Empfängeradresse beenden? Dateien und Medien sind nicht erlaubt! Nur Gruppenbesitzer können Dateien und Medien aktivieren. - Mitglieder können Dateien und Medien senden. + Mitglieder können Dateien und Medien versenden. Der Wechsel der Empfängeradresse wird beendet. Die bisherige Adresse wird weiter verwendet. Dateien und Medien sind nicht erlaubt. Favorit entfernen @@ -1466,7 +1465,7 @@ Profil erstellen %s und %s Ihrer Gruppe beitreten? - %1$s.]]> + %1$s.]]> Das ist Ihr eigener Einmal-Link! %d Nachrichten als gelöscht markiert Gruppe besteht bereits! @@ -1484,7 +1483,7 @@ %1$s.]]> Das ist Ihre eigene SimpleX-Adresse! Richtiger Name für %s? - %d Nachrichten löschen? + Sollen %d Nachrichten gelöscht werden? Mit %1$s verbinden? Mitglied entfernen Blockieren @@ -1653,8 +1652,8 @@ Private Notizen Es werden alle Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Private Notizen entfernen? - %s wurde blockiert - %s wurde freigegeben + es wurde %s blockiert + Es wurden %s freigegeben Sie haben %s blockiert Sie haben %s freigegeben Mitglied für Alle blockieren? @@ -1715,8 +1714,8 @@ Bitte bestätigen Sie für die Migration, dass Sie sich an Ihr Datenbank-Passwort erinnern. Hochladen bestätigen Herunterladen fehlgeschlagen - Ende-zu-Ende-Verschlüsselung mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt.]]> - Quantum-resistente E2E-Verschlüsselung mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt.]]> + Ende-zu-Ende-Verschlüsselung mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt.]]> + Quantum-resistente E2E-Verschlüsselung mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt.]]> Dieser Chat ist durch Ende-zu-Ende-Verschlüsselung geschützt. Dieser Chat ist durch Quantum-resistente Ende-zu-Ende-Verschlüsselung geschützt. Migrationsansicht öffnen @@ -1791,7 +1790,7 @@ SimpleX-Links sind nicht erlaubt Sprachnachrichten sind nicht erlaubt SimpleX-Links - Mitglieder können SimpleX-Links senden. + Mitglieder können SimpleX-Links versenden. Administratoren Alle Mitglieder Aktiviert für @@ -1880,7 +1879,7 @@ Bild entfernen Wiederholen Skalieren - Default-Design einstellen + Default-Design festlegen Wallpaper-Akzent Wallpaper-Hintergrund Anwenden auf @@ -1907,7 +1906,7 @@ Privates Nachrichten-Routing 🚀 Schützen Sie Ihre IP-Adresse vor den Nachrichten-Relais , die Ihr Kontakt ausgewählt hat. \nAktivieren Sie es in den *Netzwerk & Server* Einstellungen. - Dateien sicher empfangen + Dateien sicher herunterladen Mit reduziertem Akkuverbrauch. Keine Information Debugging-Zustellung @@ -2025,8 +2024,7 @@ Zoom SMP-Server Informationen zeigen für - Beginnend mit %s. -\nAlle Daten werden nur auf Ihrem Gerät gespeichert. + Beginnend mit %s.\nAlle Daten werden nur auf Ihrem Gerät gespeichert. Statistiken Transport-Sitzungen Hochgeladen @@ -2091,10 +2089,10 @@ Einstellungen Die Nachrichten werden für alle Gruppenmitglieder gelöscht. Die Nachrichten werden für alle Mitglieder als moderiert markiert. - %d Nachrichten der Mitglieder löschen? + Sollen %d Nachrichten von Mitgliedern gelöscht werden? Nachricht Nachrichten werden zur Löschung markiert. Der/Die Empfänger hat/haben die Möglichkeit, diese Nachrichten aufzudecken. - %d ausgewählt + Es wurden %d ausgewählt Es wurde Nichts ausgewählt Auswählen Einladen @@ -2156,7 +2154,7 @@ Die Nachrichten wurden gelöscht, nachdem Sie sie ausgewählt hatten. Es gibt nichts zum Weiterleiten! %1$d andere(r) Datei-Fehler. - %1$s Nachricht(en) weiterleiten? + Soll(en) %1$s Nachricht(en) weitergeleitet werden? %1$d Datei(en) wurde(n) gelöscht. %1$d Datei(en) wurde(n) nicht heruntergeladen. Nachrichten ohne Dateien weiterleiten? @@ -2193,7 +2191,7 @@ Fehler in der Server-Konfiguration. Für das Chat-Profil %s: Keine Medien- und Dateiserver. - Keine Server für den Empfang von Dateien. + Keine Server für das Herunterladen von Dateien. Keine Server für das Versenden von Dateien. Nicht ausgelieferte Nachrichten Die SimpleX-Adresse auf sozialen Medien teilen. @@ -2248,7 +2246,7 @@ Der Server-Betreiber wurde geändert. Das Server-Protokoll wurde geändert. Transparenz - Flux aktivieren + Für einen besseren Metadatenschutz Flux in den Netzwerk- und Servereinstellungen aktivieren. Dezentralisiertes Netzwerk Der zweite voreingestellte Netzwerk-Betreiber in der App! Verbesserte Chat-Navigation @@ -2286,7 +2284,7 @@ Ende-zu-Ende-verschlüsselt versendet. In Direktnachrichten sogar mit Post-Quantum-Security.]]> Team-Mitglieder aufnehmen Freunde aufnehmen - Einladung akzeptiert + Einladung angenommen Geschäftliche Adresse Geschäftliche Chats Nehmen Sie Team-Mitglieder in Ihre Unterhaltungen auf. @@ -2320,6 +2318,196 @@ Wenn mehr als ein Betreiber aktiviert ist, hat keiner von ihnen Metadaten, um zu erfahren, wer mit wem kommuniziert. Chat %1$s verbunden.]]> - Über Betreiber + Über die Betreiber SimpleX-Chat und Flux haben vereinbart, die von Flux betriebenen Server in die App aufzunehmen. - \ No newline at end of file + Die Verbindung erfordert eine Neuverhandlung der Verschlüsselung. + Die Neuverhandlung der Verschlüsselung läuft. + Reparieren + Verbindung reparieren? + Log-Daten aktivieren + Fehler beim Speichern der Datenbank + Verbindung noch nicht bereit. + Alle + Unternehmen + Fehler beim Erstellen der Chat-Liste + Fehler beim Laden der Chat-Listen + Fehler beim Aktualisieren der Chat-Liste + Favoriten + Liste + Keine Chats + Keine Chats gefunden + Keine Chats in der Liste %s. + Keine ungelesenen Chats + Liste erstellen + Löschen + Liste löschen? + Bearbeiten + Listenname... + Der Listenname und das Emoji sollen für alle Listen unterschiedlich sein. + Liste speichern + Alle Chats werden von der Liste %s entfernt und die Liste wird gelöscht + Gruppen + Kontakte + Liste hinzufügen + Zur Liste hinzufügen + Mit %s öffnen + Anmerkungen + Anordnung ändern + Liste ändern + Fehler beim Erstellen der Meldung + Fehler beim Abspeichern der Einstellungen + Archivierte Meldung + Spam melden: Nur Gruppenmoderatoren werden es sehen. + Melden + Anderer Grund + Meldung archivieren? + Moderator + Inhalt melden: Nur Gruppenmoderatoren werden es sehen. + Unangemessener Inhalt + Unangemessenes Profil + Nur Absender und Moderatoren sehen es + Nur Sie und Moderatoren sehen es + Spam + Archiv + Grund der Meldung? + Die Meldung wird für Sie archiviert. + Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen. + Anderes melden: Nur Gruppenmoderatoren werden es sehen. + Verstoß melden: Nur Gruppenmoderatoren werden es sehen. + Meldung archivieren + Meldung löschen + Verstoß gegen die Gemeinschaftsrichtlinien + Archivierte Meldung von %s + Eine Meldung + %d Meldungen + Mitglieder-Meldungen + Meldungen + Inhalt verletzt Nutzungsbedingungen + Spam + Verbindung blockiert + Die Datei wird vom Serverbetreiber blockiert:\n%1$s. + Die Verbindung wird vom Serverbetreiber blockiert:\n%1$s. + Fragen + Nein + Web-Link öffnen + Web-Links aus der Chat-Liste öffnen + Web-Link öffnen? + Ja + Chat-Name festlegen… + Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden! + Automatisches Löschen von Nachrichten ändern? + Chat-Nachrichten von Ihrem Gerät löschen + Automatisches Löschen von Nachrichten deaktivieren? + Löschen von Nachrichten deaktivieren + Älter als ein Jahr + Default (%s) + Nachrichten in diesem Chat werden nie gelöscht. + Solange kein Port konfiguriert ist, wird TCP-Port %1$s genutzt. + Web-Port nutzen + TCP-Port für Nachrichtenübermittlung + Sie können bis zu %1$s Mitglieder pro Nachricht erwähnen! + Alle stummschalten + Ungelesene Erwähnungen + Melden von Nachrichten ist in dieser Gruppe nicht erlaubt. + Alle Meldungen archivieren? + Archiviere %d Meldungen? + Für alle Moderatoren + Für mich + Meldungen archivieren + Mitglieder können Nachrichten an Moderatoren melden. + Melden von Nachrichten an Moderatoren nicht erlauben. + Melden von Nachrichten an Moderatoren erlauben. + Alle Meldungen werden für Sie archiviert. + Meldung: %s + Helfen Sie Administratoren bei der Moderation ihrer Gruppen. + Erwähnung von Mitgliedern 👋 + Private Meldungen senden + Bei Erwähnung benachrichtigt werden. + Medien mit anonymisierten Dateinamen. + Verfallsdatum von Nachrichten in Chats festlegen. + Bessere Leistung von Gruppen + Schnelleres löschen von Gruppen + Schnelleres versenden von Nachrichten. + abgelehnt + Bessere(r) Security und Datenschutz + Verpassen Sie keine wichtigen Nachrichten. + Chats in Listen verwalten + abgelehnt + Das Passwort kann nicht aus dem Schlüsselbund gelesen werden. Dies kann nach einer Systemaktualisierung passiert sein, die nicht mit der App kompatibel war. Wenden Sie sich bitte an die Entwickler, wenn dies nicht der Fall ist. + ausstehend + Fehler beim Lesen des Datenbank-Passworts + Aktualisierte Nutzungsbedingungen + ausstehende Genehmigung + Das Passwort kann nicht aus dem Schlüsselbund gelesen werden. Bitte geben Sie es manuell ein. Dies kann nach einer Systemaktualisierung passiert sein, die nicht mit der App kompatibel war. Wenden Sie sich bitte an die Entwickler, wenn dies nicht der Fall ist. + Mitglieder entfernen? + Mitglieder werden aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden! + Mitglieder werden aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! + Mitglieder für Alle freigeben? + Nachrichten dieser Mitglieder werden angezeigt! + Moderatoren + Mitglieder für Alle blockieren? + Alle neuen Nachrichten dieser Mitglieder werden nicht angezeigt! + Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam. + Datenschutz- und Nutzungsbedingungen. + Akzeptieren + Server-Betreiber konfigurieren + Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. + Verbindungs-Link wird nicht unterstützt + Kurze Links verwenden (BETA) + Verkürzter Link + Vollständiger Link + SimpleX-Kanal-Link + Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. + Alle Server + Aus + TCP-Port 443 nur für voreingestellte Server verwenden. + Voreingestellte Server + %d Chats mit Mitgliedern + %d Chat(s) + Meldung wurde an die Moderatoren gesendet + Sie haben dieses Mitglied übernommen + Überprüfung der Mitglieder vor der Aufnahme (\"Anklopfen\"). + Überprüfung der Mitglieder + alle + Aus + Als Beobachter übernehmen + Mitglied übernehmen + Chats mit Mitgliedern + Chat mit Administratoren + Keine Chats mit Mitgliedern + Entfernen + hat Sie übernommen + Chat mit einem Mitglied + %d Nachrichten + Ein Mitglied wird der Gruppe beitreten. Übernehmen? + Ein neues Mitglied will der Gruppe beitreten. + Überprüfung + Von Administratoren überprüft + Aufnahme von Mitgliedern festlegen + Speichern der Aufnahme-Einstellungen? + Sie können Ihre Meldungen im Chat mit den Administratoren sehen. + Chat mit Administratoren + %1$s übernommen + Als Mitglied übernehmen + Fehler beim Übernehmen des Mitglieds + Aufnahme von Mitgliedern + Ausstehende Überprüfung + Chat mit einem Mitglied + Übernehmen + Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können. + Gruppe wird gelöscht + Beitrittsanfrage abgelehnt + Von der Gruppe entfernt + Sie haben die Gruppe verlassen + Kontakt deaktiviert + Nicht synchronisiert + Fehler beim Löschen des Chats mit dem Mitglied + Kontakt nicht bereit + Sie können keine Nachrichten senden! + Chat löschen + Chat mit dem Mitglied löschen? + Mitglied ablehnen? + Es können keine Nachrichten gesendet werden + Kontakt gelöscht + Das Mitglied hat eine alte App-Version + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 6163d7e873..5667c42d2d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -7,9 +7,9 @@ Bueno para la batería. La aplicación comprueba si hay mensajes cada 10 minutos. Podrías perderte llamadas o mensajes urgentes.]]> Aceptar Copia de seguridad de los datos de la aplicación - un dia - un mes - una semana + 1 día + 1 mes + 1 semana Se permiten los mensajes temporales pero sólo si tu contacto también los permite. Añadir servidores mediante el escaneo de códigos QR. Añadir servidores predefinidos @@ -20,7 +20,7 @@ Permites a tus contactos enviar mensajes de voz. siempre La aplicación sólo puede recibir notificaciones cuando se está ejecutando. No se iniciará ningún servicio en segundo plano. - ICONO APLICACIÓN + ICONO DE LA APLICACIÓN La optimización de la batería está activa, desactivando el servicio en segundo plano y las solicitudes periódicas de nuevos mensajes. Puedes volver a activarlos en Configuración. El servicio está siempre en funcionamiento en segundo plano. Las notificaciones se muestran en cuanto haya mensajes nuevos. Se puede desactivar en Configuración – las notificaciones se seguirán mostrando mientras la app esté en funcionamiento.]]> @@ -29,15 +29,14 @@ y después: ¿Aceptar solicitud de conexión\? Aceptar incógnito - Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse! + Se eliminarán todos los mensajes SOLO para tí. ¡No puede deshacerse! Añadir servidor ¿Acceder a los servidores a través del proxy SOCKS en el puerto %d\? El proxy debe iniciarse antes de activar esta opción. Todos tus contactos permanecerán conectados. Apariencia Versión por cada perfil que tengas en la aplicación.]]> - Se usará una conexión TCP (y credenciales SOCKS) independiente por cada contacto y miembro del grupo. -\nRecuerda: si tienes muchas conexiones, el consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar. + por cada contacto y miembro del grupo. \nRecuerda: si tienes muchas conexiones, el consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar.]]> a + b Acerca de SimpleX negrita @@ -50,7 +49,7 @@ Audio activado ID de mensaje erróneo Auto aceptar imágenes - Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse! + Se eliminarán todos los chats y mensajes. ¡No puede deshacerse! Aceptar Se permiten mensajes temporales. Android Keystore se usará para almacenar de forma segura la frase de contraseña - permite que el servicio de notificación funcione. @@ -73,7 +72,7 @@ Añadir a otro dispositivo Versión de la aplicación: v%s Solicita recibir la imagen - Recuerda: NO podrás recuperar o cambiar la frase de contraseña si la pierdes.]]> + Recuerda: Si la pierdes NO podrás recuperar o cambiar la frase de contraseña.]]> Tanto tú como tu contacto podéis enviar mensajes de voz. ¡Consume más energía! La aplicación está siempre en segundo plano y las notificaciones se muestran de inmediato.]]> Tanto tú como tu contacto podéis eliminar los mensajes enviados de forma irreversible. (24 horas) @@ -88,7 +87,7 @@ ¿Eliminar contacto\? ¿Eliminar mensaje\? ¿Eliminar perfil? - grupo eliminado + ha eliminado el grupo ¿Eliminar grupo\? Eliminar en Autenticación de dispositivo desactivada. Puedes habilitar Bloqueo SimpleX en Configuración, después de activar la autenticación de dispositivo. @@ -109,9 +108,9 @@ Llamada con cifrado de extremo a extremo cifrado de extremo a extremo mensaje duplicado - Herramientas desarrollo + Herramientas para desarrolladores Eliminar los archivos de todos los perfiles - Eliminar mensaje + Activar ¡Base de datos cifrada! La base de datos está cifrada con una contraseña aleatoria, puedes cambiarla. Error en base de datos @@ -130,14 +129,14 @@ %d semana %d semanas Mensajes temporales - Canfirma tus credenciales + Confirma tus credenciales conectando (presentado) conectando (invitación de presentación ) conectando (aceptado) conectando (anunciado) conexión %1$d Conecta vía enlace / Código QR - El contacto y todos los mensajes serán eliminados. ¡No podrá deshacerse! + El contacto y todos los mensajes serán eliminados. ¡No puede deshacerse! Contacto verificado el contacto dispone de cifrado de extremo a extremo Desconectar @@ -159,7 +158,7 @@ Conectado Copiado en portapapeles Crea enlace de invitación de un uso. - Escanear código QR ]]> + Escanear código QR ]]> Eliminar Eliminar ¡El contacto aun no se ha conectado! @@ -170,7 +169,7 @@ conectando llamada… Activar llamadas desde la pantalla de bloqueo en Configuración La contraseña de cifrado de la base de datos será actualizada y almacenada en Keystore. - conectando + conectando... creador %d min Tiempo de conexión agotado @@ -209,7 +208,7 @@ Eliminar grupo Editar perfil de grupo Conexión - Eliminar el perfil de chat para + Eliminar el perfil Oscuro %dd %d días @@ -217,7 +216,7 @@ conectado directa El contacto permite - predeterminado (%s) + predefinido (%s) Eliminar para todos activado Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. @@ -239,8 +238,7 @@ Email Conectar Conectar mediante enlace - Base de Datos y -\nContraseña + Base de Datos y Contraseña Contribuye Core versión: v%s Eliminar imagen @@ -273,7 +271,7 @@ Cambiar rol Mediante perfil (predeterminado) o por conexión (BETA) cambiando de servidor… - Preferencias de Chat + Preferencias generales cancelado %s SimpleX está parado LLAMADAS @@ -310,15 +308,15 @@ Error al guardar perfil de grupo Salir sin guardar Archivo guardado - Voltear la cámara + Girar la cámara Invitación de grupo caducada La invitación al grupo ya no es válida, ha sido eliminada por el remitente. - El grupo será eliminado para tí. ¡No podrá deshacerse! + El grupo será eliminado para tí. ¡No puede deshacerse! Cómo usar la sintaxis markdown en modo incógnito mediante enlace de un solo uso Dirección de contacto SimpleX Error al guardar servidores SMP - Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces SimpleX que no son de confianza aparecerán en rojo. + Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces de SimpleX que no son de confianza aparecerán en rojo. Error al actualizar la configuración de red Error al crear dirección Error al eliminar perfil @@ -330,7 +328,7 @@ Error al cambiar configuración Archivo: %s ¡Error al cambiar perfil! - Introduce el servidor manualmente + Añadir manualmente Cómo usar los servidores Error al parar SimpleX Introduce la contraseña correcta. @@ -344,7 +342,7 @@ Error al cambiar dirección Error al guardar archivo Error - De la Galería + De la galería Imagen Vídeo Si has recibido un enlace de invitación a SimpleX Chat puedes abrirlo en tu navegador: @@ -356,7 +354,7 @@ Ignorar Error al eliminar base de datos Base de datos cifrada - Error al eliminar miembro + Error al expulsar miembro Los miembros pueden enviar mensajes de voz. en modo incógnito mediante enlace de dirección del contacto ¡Error al crear perfil! @@ -412,7 +410,7 @@ Ocultar pantalla de aplicaciones en aplicaciones recientes. Cifrar Ampliar la selección de roles - El grupo será eliminado para todos los miembros. ¡No podrá deshacerse! + El grupo será eliminado para todos los miembros. ¡No puede deshacerse! Activar TCP keep-alive activado para tí error @@ -429,7 +427,7 @@ ayuda Compartir enlace Cómo funciona - El mensaje será eliminado. ¡No podrá deshacerse! + El mensaje será eliminado. ¡No puede deshacerse! El modo incógnito protege tu privacidad creando un perfil aleatorio por cada contacto. Da permiso en el siguiente diálogo para recibir notificaciones instantáneas.]]> Instalar terminal de SimpleX Chat @@ -491,7 +489,7 @@ eliminado por el moderador invitación a conectarse ¡Las notificaciones instantáneas están desactivadas! - mensaje nuevo + nuevo mensaje Nueva solicitud de contacto Inicie sesión con sus credenciales Error en la entrega del mensaje @@ -520,7 +518,7 @@ OK (sólo almacenado por miembros del grupo) Ayuda sintaxis markdown - Servidores y Redes + Servidores y Red Se usarán hosts .onion si están disponibles. cursiva Llamada audio entrante @@ -565,7 +563,7 @@ Salir ¿Salir del grupo\? propietario - El miembro será expulsado del grupo. ¡No podrá deshacerse! + El miembro será expulsado del grupo. ¡No puede deshacerse! Sólo los propietarios del grupo pueden activar los mensajes de voz. Más Marcar como verificado @@ -579,7 +577,7 @@ Error al restaurar base de datos Seleccionar contactos Guardar perfil de grupo - Restablecer colores + Reiniciar colores Sólo tú puedes enviar mensajes temporales. Sólo tu contacto puede enviar mensajes temporales. No se permiten mensajes de voz. @@ -597,7 +595,7 @@ llamada rechazada secreto Abrir SimpleX Chat para aceptar llamada - Restablecer valores predetarminados + Reiniciar a valores predetarminados Pendiente Notificaciones periódicas Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes. @@ -606,7 +604,7 @@ Notificaciones privadas imagen del perfil No se permiten mensajes de voz. - Proteger la pantalla de la aplicación + Proteger pantalla de la aplicación repositorio GitHub .]]> Grabar mensaje de voz ha expulsado a %1$s @@ -681,22 +679,21 @@ Abrir chat Restaurar copia de seguridad de la base de datos Guardar contraseña y abrir el chat - La contraseña no se ha encontrado en Keystore, introdúcela manualmente. Esto puede haber ocurrido si has restaurado los datos de la aplicación con una herramienta de copia de seguridad. Si no es así, por favor ponte en contacto con los desarrolladores. + La frase de contraseña no se ha encontrado en Keystore. Por favor, introdúcela manualmente. Puede deberse a que hayas restaurado los datos de la aplicación mediante alguna herramienta para copias de seguridad. Si no es así, por favor, ponte en contacto con los desarrolladores. Expulsar Expulsar miembro Enviar mensaje directo - Restablecer + Reiniciar Pegar Código de seguridad Escanea el código de seguridad desde la aplicación de tu contacto. Guardar servidores - Escanear código QR del servidor + Escanear código QR Servidor predefinido Guardar y notificar contacto ¿Guardar preferencias\? Guardar y notificar grupo - A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo. -\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. + A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo. \nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. La aplicación recoge nuevos mensajes periódicamente lo que consume un pequeño porcentaje de batería al día. La aplicación no usa notificaciones push por tanto los datos de tu dispositivo no se envían a los servidores push. Bloqueo SimpleX Desbloquear @@ -745,15 +742,15 @@ Configuración Altavoz desactivado Inciar chat nuevo - Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás recibir o enviar mensajes. - Gracias por instalar SimpleX Chat! - Para proteger tu privacidad, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos. + Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. + ¡Gracias por instalar SimpleX Chat! + Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos. Para proteger tu información, activa el Bloqueo SimpleX. \nSe te pedirá que completes la autenticación antes de activar esta función. - Al actualizar la configuración el cliente se reconectará a todos los servidores. + Para actualizar la configuración el cliente se reconectará a todos los servidores. ¿Usar servidores SimpleX Chat\? Enlace de grupo SimpleX - Invitación única SimpleX + Invitación SimpleX de un uso Enlaces SimpleX El servidor requiere autorización para crear colas, comprueba la contraseña Para recibir notificaciones, introduce la contraseña de la base de datos @@ -784,8 +781,8 @@ %s no está verificado Probar servidor Probar servidores - Estrella en GitHub - Lista de servidores para las conexiones nuevas del perfil + Califica en GitHub + Servidores para conexiones nuevas en tu perfil ¿Usar conexión directa a Internet\? El perfil sólo se comparte con tus contactos. inicializando… @@ -825,7 +822,7 @@ ¡Prueba no superada! Algunos servidores no han superado la prueba: Usar servidor - Usar para conexiones nuevas + Para conexiones nuevas Sistema mediante enlace de un solo uso Chats @@ -873,7 +870,7 @@ Mediante navegador mediante %1$s Servicio SimpleX Chat - ¡Bienvenido %1$s ! + ¡Bienvenido %1$s! has sido invitado al grupo Esperando archivo Esperando imagen @@ -938,7 +935,7 @@ Mis perfiles Mi dirección SimpleX Tu servidor - Dirección del servidor + Dirección de tu servidor Tu perfil actual Tu perfil es almacenado en tu dispositivo y solamente se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. Sistema @@ -995,10 +992,10 @@ Confirmar actualizaciones de la bases de datos la versión de la base de datos es más reciente que la aplicación, pero no hay migración hacia versión anterior para: %s EXPERIMENTAL - IDs de la base de datos y opciónes de aislamiento de transporte. + IDs de la base de datos y opciones de aislamiento de transporte. El archivo se recibirá cuando el contacto termine de subirlo. La imagen se recibirá cuando el contacto termine de subirla. - Mostrar opciones de desarrollador + Mostrar opciones para desarrolladores Ocultar: Mostrar: Eliminar perfil @@ -1109,7 +1106,7 @@ Abriendo base de datos… Error al introducir dirección Guía de Usuario.]]> - Enlace de un uso + Enlace de un solo uso Dirección SimpleX Cuando alguien solicite conectarse podrás aceptar o rechazar su solicitud. Compartir dirección @@ -1146,7 +1143,7 @@ Mensaje enviado Dejar de compartir ¿Dejar de compartir la dirección\? - COLORES DEL INTERFAZ + COLORES DE LA INTERFAZ Puedes crearla más tarde ¿Compartir la dirección con los contactos\? Compartir con contactos @@ -1236,7 +1233,7 @@ APLICACIÓN Reiniciar Cerrar - Las notificaciones dejarán de funcionar hasta que reinicies la aplicación + Las notificaciones dejarán de funcionar hasta que vuelvas a iniciar la aplicación Desactivado Error al cancelar cambio de dirección Sin chats filtrados @@ -1368,9 +1365,9 @@ Cifra archivos almacenados y multimedia Error al establecer contacto con el miembro Recuerda: los servidores están conectados mediante proxy SOCKS, pero las llamadas y las previsualizaciones de enlaces usan conexión directa.]]> - Cifra archivos locales + Cifrar archivos locales Nueva aplicación para ordenador! - 6 idiomas nuevos para el interfaz + 6 nuevos idiomas para la interfaz Cifrado de los nuevos archivos locales (excepto vídeos). Envía un mensaje para conectar Descubre y únete a grupos @@ -1476,14 +1473,14 @@ Ordenador encontrado ¡No compatible! Esperando conexión móvil: - Eliminar miembro + Expulsar miembro ¿Desbloquear miembro? Para permitir que la aplicación móvil se conecte al ordenador, abre este puerto en el firewall si está habilitado Usar desde ordenador Código de sesión ¿Repetir solicitud de admisión? Crear perfil de chat - ¿Eliminar miembro? + ¿Expulsar miembro? ¡Ya estás conectando mediante este enlace de un solo uso! Desenlazar El nombre del dispositivo será compartido con el cliente móvil conectado. @@ -1518,10 +1515,10 @@ Se envían hasta 100 mensajes más recientes a los miembros nuevos. Añadir contacto: crea un enlace de invitación nuevo o usa un enlace recibido.]]> No se envía el historial a los miembros nuevos. - O muestra este código QR + O muestra el código QR Hasta 100 últimos mensajes son enviados a los miembros nuevos. - El código QR escaneado no es un enlace SimpleX. - El texto pegado no es un enlace SimpleX. + El código QR escaneado no es un enlace de SimpleX. + El texto pegado no es un enlace de SimpleX. Permitir acceso a la cámara Podrás ver el enlace de invitación en detalles de conexión. ¿Guardar invitación no usada? @@ -1545,7 +1542,7 @@ Error al crear mensaje Error al eliminar notas privadas ¿Eliminar notas privadas? - Opciones desarrollador + Opciones para desarrolladores ha bloqueado a %s ha desbloqueado a %s has bloqueado a %s @@ -1555,7 +1552,7 @@ ¿Bloqear miembro para todos? Creado: %s Bloquear para todos - ¿Desbloquear miembro para todos? + ¿Desbloquear el miembro para todos? Desbloquear para todos bloqueado bloqueado por administrador @@ -1573,15 +1570,14 @@ El ordenador está inactivo El ordenador está ocupado Error crítico - Todos los mensajes serán borrados. ¡No podrá deshacerse! + Todos los mensajes serán eliminados. ¡No puede deshacerse! ¿Iniciar chat? Mensaje de bienvenida demasiado largo Tiempo de espera para conectar con el ordenador agotado El ordenador tiene un código de invitación incorrecto El ordenador ha sido desconectado estado desconocido - Migración de la base de datos en progreso. -\nPodría tardar varios minutos. + Migración de la base de datos en curso. \nPodría tardar varios minutos. El ordenador tiene una versión sin soporte. Por favor, asegúrate de usar la misma versión en ambos dispositivos el contacto %1$s ha cambiado a %2$s perfil actualizado @@ -1687,8 +1683,8 @@ Error al guardar ajustes El archivo exportado no existe Para continuar, SimpleX debe estar parado. - cifrado de extremo a extremo con secreto perfecto hacía adelante, repudio y recuperación tras ataque.]]> - cifrado de extremo a extremo resistente a tecnología cuántica con secreto perfecto hacía adelante, repudio y recuperación tras ataque.]]> + cifrado de extremo a extremo con secreto perfecto hacia adelante, repudio y recuperación tras ataque.]]> + cifrado de extremo a extremo resistente a tecnología cuántica con secreto perfecto hacia adelante, repudio y recuperación tras ataque.]]> Migrar aquí Migrar a otro dispositivo Migrar a otro dispositivo mediante código QR. @@ -1715,7 +1711,7 @@ Enlaces SimpleX no permitidos Mensajes de voz no permitidos Enlaces SimpleX - Los miembros pueden enviar enlaces SimpleX. + Los miembros pueden enviar enlaces de SimpleX. Enlaces SimpleX no permitidos. propietarios Móvil @@ -1761,7 +1757,7 @@ \nError del servidor de destino: %2$s Servidor de reenvío: %1$s \nError: %2$s - Problema en la red - el mensaje ha expirado tras muchos intentos de envío. + Problema en la red - el mensaje ha caducado tras muchos intentos de envío. La versión del servidor es incompatible con la configuración de la red. Enrutamiento privado Servidores desconocidos @@ -1785,7 +1781,7 @@ La dirección del servidor es incompatible con la configuración de la red. Con IP desprotegida Clave incorrecta o conexión desconocida - probablemente esta conexión fue eliminada - Usar enrutamiento privado con servidores de retransmisión desconocidos. + Usar enrutamiento privado con servidores de mensaje desconocidos. Enviar mensajes directamente cuando tu dirección IP está protegida y tu servidor o el de destino no admitan enrutamiento privado. ¡Servidores desconocidos! Sin Tor o VPN, tu dirección IP será visible para estos relés XFTP: @@ -1811,10 +1807,10 @@ Respuesta recibida Mosaico Quitar imagen - Restablecer color + Reiniciar color Escala Respuesta enviada - Establecer tema predeterminado + Establecer tema predefinido Sistema Color de fondo Encaje @@ -1826,7 +1822,7 @@ información cola del servidor: %1$s \n \núltimo mensaje recibido: %2$s - Restablecer al tema de la aplicación + Reiniciar al tema de la aplicación Enrutamiento privado de mensajes 🚀 Recibe archivos de forma segura Mejora del envío de mensajes @@ -1838,14 +1834,13 @@ Nuevos temas de chat Información cola de mensajes ninguno - Protege tu dirección IP de los servidores de retransmisión elegidos por tus contactos. -\nActívalo en ajustes de *Servidores y Redes*. - Restablecer al tema del usuario + Protege tu dirección IP de los servidores elegidos por tus contactos.\nActívalo en *Servidores y Red*. + Reiniciar al tema del usuario Error al inicializar WebView. Actualiza tu sistema a la última versión. Por favor, ponte en contacto con los desarrolladores. \nError: %s Interfaz en persa Clave incorrecta o dirección del bloque del archivo desconocida. Es probable que el archivo se haya eliminado. - Archivo no encontrado, probablemente haya sido borrado o cancelado. + Archivo no encontrado, probablemente haya sido eliminado o cancelado. Error del servidor de archivos: %1$s Error de archivo Error en archivo temporal @@ -1859,10 +1854,10 @@ Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador. No se puede enviar el mensaje Las preferencias seleccionadas no permiten este mensaje. - Info servidores + Estadísticas servidores Archivos Mostrando - Suscrito + Suscritas Errores de suscripción Suscripciones ignoradas Para ser notificado sobre versiones nuevas, activa el chequeo periódico para las versiones Estable o Beta. @@ -1883,7 +1878,7 @@ Total Sesiones de transporte Servidor XFTP - No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado. + No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. Todos los perfiles Conectadas Estadísticas detalladas @@ -1905,10 +1900,10 @@ Error de enrutamiento privado La dirección del servidor es incompatible con la configuración de red: %1$s. La versión del servidor es incompatible con tu aplicación: %1$s. - Tamaño fuente - Error al restablecer las estadísticas - Restablecer - Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse! + Tamaño de la fuente + Error al reiniciar las estadísticas + Reiniciar + Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse! Descargado Servidor SMP Aún no hay conexión directa, el mensaje es reenviado por el administrador. @@ -1945,16 +1940,16 @@ Reconectar todos los servidores ¿Reconectar servidor? ¿Reconectar servidores? - Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional. + Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional. Reconectar todos los servidores para forzar la entrega de mensajes. Usa tráfico adicional. - Restablecer todas las estadísticas - ¿Restablecer todas las estadísticas? + Reiniciar estadísticas + ¿Reiniciar todas las estadísticas? Mensajes enviados Total enviados Archivos descargados Errores de descarga duplicados - expirados + caducados Abrir configuración del servidor otros otros errores @@ -1968,8 +1963,7 @@ Tamaño Conexiones activas Iniciado el %s. - Iniciado el %s -\nTodos los datos son privados a tu dispositivo + Iniciado el %s \nLos datos son privados en tu dispositivo. Bloques eliminados Bloques descargados Bloques subidos @@ -2003,10 +1997,10 @@ Configuración ¿Confirmas la eliminación del contacto? ¡Contacto eliminado! - El contacto será eliminado. ¡No podrá deshacerse! + El contacto será eliminado. ¡No puede deshacerse! ¡Conversación eliminada! Elimina sin notificar - Sólo borrar la conversación + Eliminar sólo la conversación Conservar conversación buscar Contactos archivados @@ -2024,7 +2018,7 @@ Por favor, pide a tu contacto que active las llamadas. Enviar mensaje para activar llamadas. Elimina hasta 20 mensajes a la vez. - Barra de herramientas accesible + Barra de menú accesible Archiva contactos para charlar más tarde. Puedes guardar el archivo exportado. Fuerte @@ -2039,7 +2033,7 @@ La dirección del servidor de destino de %1$s es incompatible con la configuración del servidor de reenvío %2$s. La versión del servidor de destino de %1$s es incompatible con el servidor de reenvío %2$s. Tus contactos - Necesitas permitir que tus contacto llamen para poder llamarles. + Debes permitir que tus contacto te llamen para poder llamarles. Error al conectar con el servidor de reenvío %1$s. Por favor, inténtalo más tarde. La dirección del servidor de reenvío es incompatible con la configuración de red: %1$s. El servidor de reenvío %1$s no ha podido conectarse al servidor de destino %2$s. Por favor, intentalo más tarde. @@ -2069,14 +2063,13 @@ ¿Reenviar mensajes sin los archivos? Asegúrate de que la configuración del proxy es correcta. %1$d archivo(s) ha(n) sido eliminado(s). - %1$d error(es) de archivo -\n%2$s + %1$d error(es) de archivo:\n%2$s La descarga ha fallado para %1$d archivo(s). %1$d archivo(s) no se ha(n) descargado. %1$s mensajes no enviados Descargar Reenviando %1$s mensajes - Los mensajes han sido borrados después de seleccionarlos. + Los mensajes han sido eliminados después de seleccionarlos. ¡Nada para reenviar! Guardando %1$s mensajes No uses credenciales con proxy. @@ -2090,10 +2083,10 @@ Tus credenciales podrían ser enviadas sin cifrar. ¿Eliminar archivo? El archivo de bases de datos subido será eliminado permanentemente de los servidores. - Los mensajes serán eliminados. ¡No podrá deshacerse! + Los mensajes serán eliminados. ¡No puede deshacerse! Error al cambiar perfil Selecciona perfil de chat - Comparte perfil + Perfil a compartir Tu conexión ha sido trasladada a %s pero ha ocurrido un error inesperado al redirigirte al perfil. Sonido silenciado Error al iniciar WebView. Asegúrate de tener WebView instalado y que sea compatible con la arquitectura amr64.\nError: %s @@ -2101,12 +2094,12 @@ Esquinas Cola Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación. - Sesión de aplicación + por sesión Abre la configuración de Safari / Sitios Web / Micrófono y a continuación selecciona Permitir para localhost. Pulsa el botón info del campo dirección para permitir el uso del micrófono. Para hacer llamadas, permite el uso del micrófono. Cuelga e intenta llamar de nuevo. - Se usarán credenciales SOCKS nuevas por cada servidor. - Servidor + Se usarán credenciales SOCKS nuevas para cada servidor. + por servidor Llamadas mejoradas Sistema de fechas mejorado. Experiencia de usuario mejorada @@ -2115,18 +2108,18 @@ Protocolos de SimpleX auditados por Trail of Bits. Intercambia audio y video durante la llamada. Seguridad mejorada ✅ - Borra o modera hasta 200 mensajes a la vez. + Elimina o modera hasta 200 mensajes a la vez. Cambia el perfil de chat para invitaciones de un solo uso. Error al guardar servidores Error en la configuración del servidor. Para el perfil de chat %s: - Ningún servidor de mensajes. - Ningún servidor para recibir archivos. - Ningún servidor para enviar archivos. + Sin servidores para mensajes. + Sin servidores para recibir archivos. + Sin servidores para enviar archivos. Seguridad de conexión Compartir enlace de un uso con un amigo Comparte tu dirección SimpleX en redes sociales. - Configuración de dirección + Ajustes de dirección Crear enlace de un uso Para redes sociales ¿Dirección SimpleX o enlace de un uso? @@ -2142,7 +2135,7 @@ %s servidores Las condiciones serán aceptadas el: %s. Condiciones de uso - Para el enrutamiento privado + Para enrutamiento privado Error al añadir servidor Abrir cambios Abrir condiciones @@ -2158,49 +2151,49 @@ Condiciones aceptadas Servidores de archivos y multimedia añadidos Servidores de mensajes añadidos - ¿Dirección o enlace de un uso? + ¿Dirección o enlace de un solo uso? Las condiciones serán aceptadas automáticamente para los operadores habilitados el: %s. Continuar El texto con las condiciones actuales no se ha podido cargar, puedes revisar las condiciones en el siguiente enlace: - Habilita Flux + Activa Flux en Servidores y Red para mejorar la privacidad de los metadatos. Error al aceptar las condiciones Error al actualizar el servidor para mejorar la privacidad de los metadatos. Ningún mensaje Servidor nuevo - Ningún servidor de archivos y multimedia. - Ningún servidor para enrutamiento privado. - Ningún servidor para recibir mensajes. + Sin servidores para archivos y multimedia. + Sin servidores para enrutamiento privado. + Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado Selecciona los operadores de red a utilizar - Campartir dirección públicamente - Compartir los enlaces de un uso y las direcciones SimpleX es seguro a través de cualquier medio. + Compartir dirección públicamente + Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. Actualizar Sitio web Tus servidores Usar %s Usar servidores - Usar para mensajes + Uso para mensajes Ver condiciones Para recibir Para enviar - Usar para archivos + Uso para archivos Transparencia Ver condiciones actualizadas Mensajes no entregados solamente con un contacto - comparte en persona o mediante cualquier aplicación de mensajería.]]> Puedes añadir un nombre a la conexión para recordar a quién corresponde. La aplicación protege tu privacidad mediante el uso de diferentes operadores en cada conversación. - Puedes configurar los operadores desde Servidores y Redes. + Puedes configurar los operadores desde los ajustes de Servidores y Red. %s.]]> %s.]]> %s.]]> %s.]]> %s.]]> %s.]]> - %s, acepta las condiciones de uso.]]> - Los servidores para archivos nuevos en tu perfil actual + %s, debes aceptar las condiciones de uso.]]> + Servidores para enviar archivos en tu perfil ¡Segundo operador predefinido! Puedes configurar los servidores a través de su configuración. Para protegerte contra una sustitución del enlace, puedes comparar los códigos de seguridad con tu contacto. @@ -2209,7 +2202,7 @@ Por ejemplo, si tu contacto recibe a través de un servidor de SimpleX Chat, tu aplicación enviará a través de un servidor de Flux. Pulsa Crear dirección SimpleX en el menú para crearla más tarde. La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado. - El mensaje ha sido borrado o aún no se ha recibido. + El mensaje ha sido eliminado o aún no se ha recibido. Móvil remoto O importa desde un archivo Mensajes directos entre miembros de este chat no permitidos. @@ -2217,18 +2210,18 @@ Por favor, reduce el tamaño del mensaje y envíalo de nuevo. Por favor, reduce el tamaño del mensaje o elimina los archivos y envíalo de nuevo. Puedes copiar y reducir el tamaño del mensaje para enviarlo. - Añade a los miembros de tu equipo a las conversaciones. + Añade a miembros de tu equipo a las conversaciones. Notificaciones y batería Invitar al chat Añadir amigos Añadir miembros del equipo - El chat será eliminado para todos los miembros. ¡No podrá deshacerse! + El chat será eliminado para todos los miembros. ¡No puede deshacerse! Eliminar chat ¿Eliminar chat? Salir del chat - El chat será eliminado para tí. ¡No podrá deshacerse! + El chat será eliminado para tí. ¡No puede deshacerse! Sólo los propietarios del chat pueden cambiar las preferencias. - El miembro será eliminado del chat. ¡No podrá deshacerse! + El miembro será eliminado del chat. ¡No puede deshacerse! El rol cambiará a %s. Todos serán notificados. Dejarás de recibir mensajes de este chat. El historial del chat se conserva. Cómo ayuda a la privacidad @@ -2243,7 +2236,7 @@ Comprobar mensajes cada 10 min. Sin servicio en segundo plano Chat - Barra de herramientas accesible + Barra de chat accesible Mensajes directos entre miembros no permitidos. %1$s.]]> ¡El chat ya existe! @@ -2251,5 +2244,147 @@ La aplicación siempre funciona en segundo plano cifrados de extremo a extremo y con seguridad postcuántica en mensajes directos.]]> ¡Mensaje demasiado largo! - Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación. - \ No newline at end of file + Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. + Activar registros + Error al guardar base de datos + La conexión requiere renegociar el cifrado. + Renegociación de cifrado en curso. + Reparar + ¿Reparar conexión? + Conexión no establecida. + Error al cargar listas de chat + Error al actualizar las listas de chat + Favoritos + Lista + Sin chats + Ningún chat encontrado + Sin chats en la lista %s. + Añadir lista + Todo + Empresas + Contactos + Grupos + Abrir con %s + Añadir a la lista + Todos los chats se quitarán de la lista %s y esta será eliminada + Crear lista + Eliminar + ¿Eliminar lista? + Editar + Nombre de la lista... + El nombre y el emoji deben ser diferentes en todas las listas. + Guardar lista + Ningún chat sin leer + Error al crear lista de chat + Notas + Reordenar + Cambiar lista + Error al crear informe + Error al guardar la configuración + informe archivado por %s + ¿Archivar informe? + El informe será archivado para ti. + Informar + Eliminar informe + 1 informe + Informes + Informar de spam: sólo los moderadores del grupo lo verán. + Informar de violación: sólo los moderadores del grupo lo verán. + Informar de contenido: sólo los moderadores del grupo lo verán. + Informar de otros: sólo los moderadores del grupo lo verán. + moderador + El contenido viola las condiciones de uso. + Spam + Conexión bloqueada + Conexión bloqueada por el operador del servidor:\n%1$s. + ¿Motivo del informe? + Archivo bloqueado por el operador del servidor:\n%1$s + Archivar + Archivar informe + Informes de miembros + %d informes + Informar del perfil de un miembro: sólo los moderadores del grupo lo verán. + Otro motivo + informes archivados + Violación de las normas de la comunidad + Contenido inapropiado + Perfil inapropiado + Solo el remitente y el moderador pueden verlo + Solo tú y los moderadores podéis verlo + Spam + Abrir enlaces desde listado de chats + Si + No + ¿Abrir enlace web? + Preguntar + Abrir enlace + Nombre para el chat… + ¿Desactivar la eliminación automática de mensajes? + Desactivar + ¿Modificar la eliminación automática de mensajes? + Elimina los mensajes del dispositivo + Los mensajes de esta conversación nunca se eliminan. + Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No puede deshacerse! + 1 año + predefinido (%s) + Puerto TCP para mensajes + Se usa el puerto TCP %1$s cuando no se ha especificado otro. + Usar puerto web + Silenciar todo + Menciones sin leer + ¡Puedes mencionar hasta %1$s miembros por mensaje! + Los miembros pueden informar de mensajes a los moderadores. + No se permite informar de mensajes a los moderadores. + Se permite informar de mensajes a los moderadores. + Informe: %s + ¿Archivar %d informes? + Archivar informes + Para todos los moderadores + para mí + No se permite informar de mensajes en este grupo. + Todos los informes serán archivados para ti. + ¿Archivar todos los informes? + Las menciones ahora se notifican. + Menciona a miembros 👋 + No pierdas los mensajes importantes. + Organiza tus chats en listas + Nombres privados en archivos de media. + Eliminación más rápida de grupos. + Envío más rápido de mensajes. + Ayuda a los admins a moderar sus grupos. + Rendimiento de grupos mejorado + Privacidad y seguridad mejoradas + Envía informes privados + Establece el vencimiento para los mensajes en los chats. + rechazado + rechazado + ¿Expulsar miembros? + ¡Los mensajes de estos miembros serán mostrados! + ¿Desbloquear los miembros para todos? + ¡Todos los mensajes nuevos de estos miembros estarán ocultos! + ¿Bloquear miembros para todos? + Los miembros serán expulsados del chat. ¡No puede deshacerse! + Condiciones actualizadas + moderadores + Los miembros serán expulsados del grupo. ¡No puede deshacerse! + pendiente de aprobación + pendiente + Error al leer la frase de contraseña de la base de datos + La frase de contraseña no se ha podido leer en Keystore. Puede deberse a alguna actualización del sistema incompatible con la aplicación. Si no es así, por favor, ponte en contacto con los desarrolladores. + La frase de contraseña no se ha podido leer en Keystore. Por favor, introdúcela manualmente. Puede deberse a alguna actualización del sistema incompatible con la aplicación. Si no es así, por favor, ponte en contacto con los desarrolladores. + Aceptar + Política de privacidad y condiciones de uso. + Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores. + Al usar SimpleX Chat, aceptas:\n- enviar únicamente contenido legal en los grupos públicos.\n- respetar a los demás usuarios - spam prohibido. + Configurar operadores de servidores + Enlace de canal SimpleX + Enlace completo + Enlace corto + Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible. + Enlace de conexión no compatible + Usar enlaces cortos (BETA) + Usar puerto TCP 443 solo en servidores predefinidos. + Todos los servidores + Servidores predefinidos + No + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 866506460c..b8e99587f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -1850,4 +1850,5 @@ \nلطفا هر مشکل دیگری را با توسعه‌دهندگان به اشتراک بگذارید. این لینک توسط موبایل دیگری استفاده شده است، لطفا لینک جدیدی در کامپیوتر بسازید. خطای سرور پرونده:%1$s - \ No newline at end of file + %1$d خطای پرونده:\n%2$s + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 26847aeaf5..1dd6598ef3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1501,4 +1501,4 @@ Yhteensopimaton versio Uusi mobiililaite Tämä laite - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 62891fd8b8..43fcb2f5f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -80,14 +80,14 @@ Notifications instantanées ! Les notifications instantanées sont désactivées ! Il peut être désactivé via les paramètres - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.]]> - L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres. + Lorsque l\'optimisation de la batterie est activée, le service en arrière-plan et les demandes périodiques de nouveaux messages sont désactivés. Il est possible de les réactiver dans les paramètres. Notifications périodiques Les notifications périodiques sont désactivées ! Une phrase secrète est nécessaire - autoriser SimpleX à fonctionner en arrière-plan dans la fenêtre de dialogue suivante. Sinon, les notifications seront désactivées.]]> + Autoriser le dans la boîte de dialogue suivante pour recevoir des notifications instantanément.]]> Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs. - SimpleX service de fond - il utilise quelques pour cent de la batterie par jour.]]> + SimpleX fonctionne en arrière-plan au lieu d\'utiliser les notifications push.]]> Cacher Aperçu affiché Nom du contact @@ -147,8 +147,8 @@ Vous n\'avez aucune discussion Trop d’images ! Partager le fichier… - Attacher - Annuler l’aperçu d’image + Joindre + Annuler l\'aperçu de l\'image Annuler l’aperçu du fichier échec d’envoi non lu @@ -275,7 +275,7 @@ E-mail Se connecter L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé. - Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles. + Le service est toujours en cours d’exécution en arrière-plan, les notifications s’afficheront dès que les messages seront disponibles. Afficher le contact et le message Masquer le contact et le message Connectez-vous en utilisant votre identifiant @@ -302,7 +302,7 @@ Lien invalide ! Ce lien n\'est pas un lien de connexion valide ! Demande de connexion envoyée ! - Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard ! + Le fichier sera reçu lorsque votre contact sera en ligne, veuillez patienter ou vérifier plus tard ! Message vocal… La taille maximale supportés des fichiers actuellement est de %1$s. Message vocal (%1$s) @@ -370,11 +370,11 @@ confimation reçu… connexion… N\'importe qui peut heberger un serveur. - Pour protéger votre vie privée, au lieu d\'IDs utilisés par toutes les autres plateformes, SimpleX possède des IDs pour les queues de messages, distinctes pour chacun de vos contacts. + Pour protéger votre vie privée, SimpleX utilise des identifiants distincts pour chacun de vos contacts. Collez le lien que vous avez reçu Utiliser le chat Notifications privées - Peut être modifié ultérieurement via les paramètres. + Comment il affecte la batterie Quand l\'application fonctionne Périodique Instantanée @@ -438,7 +438,7 @@ en attente de confirmation… connecté terminé - La nouvelle génération \nde messagerie privée + L\'avenir de la messagerie La vie privée redéfinie Aucun identifiant d\'utilisateur. Protégé du spam @@ -448,12 +448,12 @@ Établir une connexion privée Comment ça fonctionne Comment SimpleX fonctionne - chiffrement de bout en bout à deux couches.]]> + Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages. GitHub repository.]]> Batterie peu utilisée. L\'app vérifie les messages toutes les 10 minutes. Vous risquez de manquer des appels ou des messages urgents.]]> Consomme davantage de batterie L\'app fonctionne toujours en arrière-plan - les notifications s\'affichent instantanément.]]> %1$d message(s) manqué(s) - ID de message incorrecte + ID du message incorrect PARAMÈTRES Cela peut arriver quand : \n1. Les messages ont expiré dans le client expéditeur après 2 jours ou sur le serveur après 30 jours. @@ -575,7 +575,7 @@ %1$s veut se connecter à vous via Vos appels Se connecter via relais - Appels en écran verrouillé : + Appels sur l\'écran de verrouillage : Afficher Désactiver Vos serveurs ICE @@ -595,12 +595,12 @@ Appel manqué Appel en connexion Répondre à l\'appel - hash de message incorrect + mauvais hachage du message message dupliqué Messages manqués Vie privée et sécurité Protéger l\'écran de l\'app - Images auto-acceptées + Acceptation automatique des images Sauvegarde des données de l\'app VOUS AIDE @@ -638,7 +638,7 @@ Changer la phrase secrète de la base de données \? La base de données sera chiffrée. Erreur de la keychain - Fichier : %s + Fichier : %s La phrase secrète de la base de données est nécessaire pour ouvrir le chat. Enregistrer la phrase secrète et ouvrir le chat Ouvrir le chat @@ -753,8 +753,8 @@ Système Autoriser l\'envoi de messages directs aux membres. Interdire l\'envoi de messages directs aux membres. - Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés. (24 heures) - La suppression irréversible de messages est interdite dans ce groupe. + Les membres peuvent supprimer de manière irréversible les messages envoyés. (24 heures) + La suppression irréversible de messages est interdite. Envoi via État du réseau Changer d\'adresse de réception @@ -789,8 +789,8 @@ Seulement vous pouvez envoyer des messages éphémères. Vous seul pouvez envoyer des messages vocaux. Autoriser la suppression irréversible de messages envoyés. (24 heures) - Les messages éphémères sont interdits dans ce groupe. - Les membres du groupe peuvent envoyer des messages vocaux. + Les messages éphémères sont interdits. + Les membres peuvent envoyer des messages vocaux. Supprimer après %d sec %ds @@ -818,7 +818,7 @@ Autorise votre contact à envoyer des messages éphémères. directe Entièrement décentralisé – visible que par ses membres. - Les membres du groupes peuvent envoyer des messages éphémères. + Les membres peuvent envoyer des messages éphémères. Interdire l’envoi de messages éphémères. Le mode incognito protège votre vie privée en utilisant un nouveau profil aléatoire pour chaque contact. La mise à jour des ces paramètres reconnectera le client à tous les serveurs. @@ -828,8 +828,8 @@ Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise. (24 heures) Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer comme supprimé). (24 heures) Seulement votre contact peut envoyer des messages éphémères. - Vous et votre contact êtes tous deux en mesure d\'envoyer des messages éphémères. - Les messages vocaux sont interdits dans ce groupe. + Vous et votre contact pouvez envoyer des messages éphémères. + Les messages vocaux sont interdits. Saisir le nom du groupe : indirecte (%1$s) Groupe @@ -842,9 +842,9 @@ Réinitialisation des valeurs par défaut Délai du protocole Intervalle de PING - Vous et votre contact êtes tous deux en mesure de supprimer de manière irréversible les messages envoyés. (24 heures) + Vous et votre contact pouvez supprimer de manière irréversible les messages envoyés. (24 heures) La suppression irréversible de message est interdite dans ce chat. - Vous et votre contact êtes tous deux en mesure d\'envoyer des messages vocaux. + Vous et votre contact pouvez envoyer des messages vocaux. Seul votre contact peut envoyer des messages vocaux. Les messages vocaux sont interdits dans ce chat. Les messages éphémères sont interdits dans cette discussion. @@ -853,7 +853,7 @@ Autorise l’envoi de messages éphémères. Interdire l’envoi de messages éphémères. Interdire la suppression irréversible des messages. - Les membres du groupe peuvent envoyer des messages directs. + Les membres peuvent envoyer des messages directs. Les messages directs entre membres sont interdits dans ce groupe. Les destinataires voient les mises à jour au fur et à mesure que vous les tapez. Vérifier la sécurité de la connexion @@ -872,7 +872,7 @@ Les messages envoyés seront supprimés après une durée déterminée. Messages dynamiques Accepter - Demandes de contact auto-acceptées + Acceptation automatique des demandes de contact Quoi de neuf \? Les admins peuvent créer les liens qui permettent de rejoindre les groupes. Définir 1 jour @@ -916,11 +916,11 @@ Vous avez déjà un profil de chat avec ce même nom affiché. Veuillez choisir un autre nom. Nom d\'affichage en double ! Interface en français - Par profil de chat (par défaut) ou par connexion (BETA). + Par profil de discussion (par défaut) ou par connexion (BETA). Interface en italien Brouillon de message - Plus d\'améliorations à venir ! - Différents profils de chat + D\'autres améliorations sont à venir ! + Profils de discussion multiples Conserver le brouillon du dernier message, avec les pièces jointes. Réduction de la consommation de batterie Noms de fichiers privés @@ -947,7 +947,7 @@ Ajouter un message d\'accueil Modération de groupe Cacher - Mute en cas d\'inactivité ! + Mise en sourdine en cas d\'inactivité ! Confirmer le mot de passe Réduction accrue de l\'utilisation de la batterie Interface en chinois et en espagnol @@ -1035,7 +1035,7 @@ SimpleX Lock n\'est pas activé ! Authentification du système Authentification - Echec de l\'authentification + Échec de l’authentification Modifier le code d\'accès Code d\'accès actuel %d minutes @@ -1055,18 +1055,17 @@ Code d\'accès défini ! Système Authentification annulée - Mauvais ID de message - Le hash du message précédent est différent. + ID du message incorrect + Le hash du message précédent est différent.\" L\'ID du message suivant est incorrect (inférieur ou égal au précédent). \nCela peut se produire en raison d\'un bug ou lorsque la connexion est compromise. Erreur de déchiffrement Cela peut se produire lorsque vous ou votre contact avez utilisé une ancienne sauvegarde de base de données. - Mauvais hash de message + Mauvais hachage du message Autoriser les appels que si votre contact les autorise. Autorise vos contacts à vous appeler. Appels audio/vidéo - " -\nDisponible dans la v5.1" + \nDisponible dans la v5.1 Interdire les appels audio/vidéo. Le fichier sera supprimé des serveurs. Révoquer @@ -1075,7 +1074,7 @@ L\'envoi du fichier sera interrompu. Veuillez le signaler aux développeurs. Soumettre - Vous et votre contact pouvez tous deux passer des appels. + Vous et votre contact pouvez passer des appels. Vous seul pouvez passer des appels. Les appels audio/vidéo sont interdits. Confirmer le code d\'accès @@ -1119,7 +1118,7 @@ Partager l\'adresse Vous pouvez partager cette adresse avec vos contacts pour leur permettre de se connecter avec %s. Aperçu - Fond + Arrière-plan Thème sombre Exporter le thème Importer un thème @@ -1133,7 +1132,7 @@ Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d\'autres personnes. La mise à jour du profil sera envoyée à vos contacts. Secondaire supplémentaire Tous vos contacts resteront connectés. La mise à jour du profil sera envoyée à vos contacts. - Auto-accepter + Acceptation automatique Créer une adresse SimpleX Personnaliser le thème Continuer @@ -1144,7 +1143,7 @@ Bonjour ! \nContactez-moi via SimpleX Chat : %s Si vous ne pouvez pas vous rencontrer en personne, montrez le code QR lors d\'un appel vidéo ou partagez le lien. - Ouvrir les profils de chat + Changer de profil de discussion Menus et alertes Message reçu Assurez-vous que le fichier a une syntaxe YAML correcte. Exporter le thème pour avoir un exemple de la structure du fichier du thème. @@ -1181,8 +1180,8 @@ Vous seul pouvez ajouter des réactions aux messages. Autoriser les réactions aux messages. Interdire les réactions aux messages. - Les membres du groupe peuvent ajouter des réactions aux messages. - Les réactions aux messages sont interdites dans ce groupe. + Les membres peuvent ajouter des réactions aux messages. + Les réactions aux messages sont interdites. heures minutes secondes @@ -1250,8 +1249,8 @@ Délai d\'attente du protocole par KB Fichiers et médias interdits ! Permet l\'envoi de fichiers et de médias. - Les membres du groupe peuvent envoyer des fichiers et des médias. - Les fichiers et les médias sont interdits dans ce groupe. + Les membres peuvent envoyer des fichiers et des médias. + Les fichiers et les médias sont interdits. Correction non prise en charge par un membre du groupe ENVOYER DES ACCUSÉS DE RÉCEPTION AUX Le chiffrement fonctionne et le nouvel accord de chiffrement n\'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! @@ -1273,7 +1272,7 @@ Activer pour tous Activer (conserver les remplacements) chiffrement accepté pour %s - Filtrer les messages non lus et favoris. + Filtrer les favoris et les messages non lus. Recherche de message plus rapide En réponse à - une diffusion plus stable des messages. @@ -1424,7 +1423,7 @@ Les messages de %s seront affichés ! Erreur lors de l\'envoi de l\'invitation Vous avez partagé un chemin de fichier non valide. Signalez le problème aux développeurs de l\'application. - Bloquer ce membre ? + Bloquer ce membre ? %d événements de groupe Nom invalide ! %1$s !]]> @@ -1480,7 +1479,7 @@ Se déconnecter auteur Connecté au portable - Mauvaise adresse de bureau + Adresse de bureau incorrecte Coller l\'adresse du bureau Vérifier le code avec le bureau Scannez le code QR du bureau @@ -1680,7 +1679,7 @@ %s envoyé Envoi de l\'archive Finaliser le transfert - Transfert terminé + Migration terminée Démarrer le chat ne devez pas utiliser la même base de données sur deux appareils.]]> Vérifier la phrase secrète de la base de données @@ -1691,7 +1690,7 @@ Échec de l\'importation Lien invalide Transférer ici - Transfert + Migration Ou coller le lien de l\'archive Ou partagez en toute sécurité le lien de ce fichier Coller le lien de l\'archive @@ -1717,10 +1716,10 @@ tous les membres Autorise l\'envoi de liens SimpleX. Activé pour - Les membres du groupe peuvent envoyer des liens SimpleX. + Les membres peuvent envoyer des liens SimpleX. propriétaires Interdire l\'envoi de liens SimpleX - Les liens SimpleX sont interdits dans ce groupe. + Les liens SimpleX sont interdits. Pas de connexion au réseau WiFi Ethernet câblé @@ -1848,7 +1847,7 @@ Erreur de fichier Erreur de fichier temporaire Statut du fichier - Statut du fichier: %s + Statut du fichier : %s Statut du message Statut du message: %s Erreur de copie @@ -1951,8 +1950,7 @@ Infos serveurs Afficher les informations pour À partir de %s. - À partir de %s. -\nToutes les données restent confinées dans votre appareil. + À partir de %s. \nToutes les données restent confinées dans votre appareil. Statistiques Total Serveur XFTP @@ -2001,7 +1999,7 @@ Pas de contacts filtrés Coller le lien Vos contacts - Barre d\'outils accessible + Barre d\'app accessible Le contact est supprimé. Les appels ne sont pas autorisés ! Vous devez autoriser votre contact à appeler pour pouvoir l\'appeler. @@ -2017,7 +2015,7 @@ Veuillez demander à votre contact d\'autoriser les appels. Envoyer un message pour activer les appels. Archiver les contacts pour discuter plus tard. - Rendez les images floues et protégez-les contre les regards indiscrets. + Flouter pour une meilleure confidentialité. Connectez-vous à vos amis plus rapidement. État de la connexion et des serveurs. Exportation de la base de données des discussions @@ -2114,4 +2112,236 @@ Protocoles SimpleX audité par Trail of Bits. Passer de l\'audio à la vidéo pendant l\'appel. Changer de profil de chat pour les invitations à usage unique. - \ No newline at end of file + rapport archivé + Ajoutez les membres de votre équipe aux conversations. + L\'application tourne toujours en arrière-plan + rapport archivé par %s + Autre raison + Archive + 1 rapport + Paramètres de l\'adresse + Barres d\'outils de l\'application + invitation acceptée + Archiver le signalement + Demander + Ajouter à la liste + Toutes les discussions seront supprimées de la liste %s, et la liste sera supprimée + Ajouter des membres à l\'équipe + Conditions acceptées + Ajouter des amis + Accepter les conditions + Ajouter une liste + Tous + À propos des opérateurs + Archiver le signalement ? + Adresse professionnelle + Discussions professionnelles + Flouter + Conditions acceptées le : %s. + Violation des directives communautaires + Conditions d\'utilisation + Créer un lien unique + %s.]]> + Les conditions seront acceptées le : %s. + %s, acceptez les conditions d\'utilisation.]]> + chiffrés de bout en bout, avec une sécurité post-quantique dans les messages directs.]]> + Réception des messages toutes les 10 minutes + %s.]]> + %s.]]> + %s.]]> + La discussion existe déjà ! + La connexion est bloquée par l\'opérateur du serveur :\n%1$s. + Les conditions seront automatiquement acceptées pour les opérateurs activés le : %s. + Le contenu viole les conditions d\'utilisation + Supprimer le rapport + La connexion n\'est pas prête. + Connexion bloquée + %s.]]> + Contacts + Continuer + Créer une liste + Supprimer + Supprimer la liste ? + Supprimer la discussion + Discussions + %s.]]> + %s.]]> + Le texte sur les conditions actuelles n\'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien : + Les messages directs entre membres sont interdits. + %1$s.]]> + Supprimer la discussion ? + Ajout de serveurs de médias et de fichiers + Ajout de serveurs de messages + %s.]]> + Appareils Xiaomi : veuillez activer le démarrage automatique dans les paramètres du système pour que les notifications fonctionnent.]]> + La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! + Le discussion sera supprimé pour vous - il n\'est pas possible de revenir en arrière ! + Les conditions seront acceptées pour les opérateurs activés après 30 jours. + La connexion nécessite une renégociation du chiffrement. + avec un seul contact - partagez en personne ou via n\'importe quelle messagerie.]]> + Adresse ou lien unique ? + Sécurité des connexions + Professionnels + Le fichier est bloqué par l\'opérateur du serveur :\n%1$s. + Favoris + %d rapports + modérateur(trice) + Décentralisation du réseau + Seuls vous et les modérateurs le voient + Seuls l\'expéditeur et les modérateurs le voient + Erreur d\'enregistrement des serveurs + Pas de serveurs pour recevoir des messages. + Pas de message + Pour les réseaux sociaux + Vérifier plus tard + Serveurs prédéfinis + Signaler autre : seuls les modérateurs du groupe le verront. + Activer les journaux (logs) + Notifications et batterie + demande à se connecter + Pas de serveurs de médias et de fichiers. + Pas de serveurs de messages. + Pas de serveurs pour recevoir des fichiers. + Spam + Erreur d\'enregistrement des paramètres + Erreur lors de la création du rapport + Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n\'importe quelle messagerie. + Spam + Signaler + Pas de discussions non lues + Groupes + Signalements + Signaler le profil d\'un membre : seuls les modérateurs du groupe le verront. + Signaler le spam : seuls les modérateurs du groupe le verront. + Ouvrir le lien + Ouvrir des liens depuis la liste de discussion + Erreur de mise à jour du serveur + Les serveurs pour les nouveaux fichiers de votre profil de discussion actuel + Serveur de l\'opérateur + Le protocole du serveur a été modifié. + Activer Flux + Ce message a été supprimé ou n\'a pas encore été reçu. + Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. + Partager publiquement votre adresse + Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. + Seuls les propriétaires peuvent modifier les préférences. + Le rôle deviendra %s. Toutes les personnes présentes dans le discussion en seront informées. + Erreur lors de la création d\'une liste de discussion + Erreur de chargement des listes de discussion + Erreur de mise à jour de la liste des discussions + Pas de discussions + Pas de discussions trouvées + Ouvrir avec %s + Sauvegarder la liste + Modifier + Partagez votre adresse SimpleX sur les réseaux sociaux. + Pas de service d\'arrière-plan + Barre de discussion accessible + Vérifier les conditions + Opérateur + Serveurs %s + Ou importer un fichier d\'archive + Ouvrir les conditions + Pour le routage privé + Pas de serveurs pour le routage privé des messages. + Erreur lors de la validation des conditions + Erreurs dans la configuration des serveurs. + Pour le profil de discussion %s : + Pas de serveurs pour envoyer des fichiers. + Veuillez réduire la taille du message et envoyer le à nouveau. + Veuillez réduire la taille du message ou supprimer le média et renvoyer le message. + pour une meilleure protection des métadonnées. + - Premier message non lu à l\'ouverture.\n- Sauter aux messages cités. + Respect de la vie privée de vos clients. + Le deuxième opérateur prédéfini de l\'application ! + Erreur lors de l\'ajout du serveur + Erreur d\'enregistrement de la base de données + Signaler un contenu : seuls les modérateurs du groupe le verront. + Opérateurs de serveur + Pas de discussions dans la liste %s. + L\'opérateur du serveur a changé. + Ouvrir le lien web ? + Signaler une infraction : seuls les modérateurs du groupe le verront. + La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. + Serveur ajouté à l\'opérateur %s. + L\'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. + Sélectionnez les opérateurs de réseau à utiliser. + SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l\'application. + Notes + Ou à partager en privé + Adresse SimpleX ou lien unique ? + Renégociation du chiffrement en cours. + Réparer + Réparer la connexion ? + Partager un lien unique avec un ami + Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. + Comment il contribue à la protection de la vie privée + Motif du signalement ? + Le rapport sera archivé pour vous. + Contenu inapproprié + Nouveau serveur + Vous pouvez configurer les serveurs via les paramètres. + Pour envoyer + Profil inapproprié + Site web + Liste + Signalement des membres + Modifier l\'ordre + Mise à jour + Les messages directs entre membres sont interdits dans cette discussion. + Non + Oui + Opérateur de réseau + Utiliser pour les fichiers + Utiliser pour les messages + Voir les conditions + Opérateurs de réseau + Inviter à discuter + Le nom de liste et l\'emoji doivent être différents pour toutes les listes. + Nom de la liste... + Quitter la discussion ? + Vous ne recevrez plus de messages de cette discussion. L\'historique sera préservé. + Le membre sera retiré de la discussion - cela ne peut pas être annulé ! + Votre profil de discussion sera envoyé aux autres membres + Vos serveurs + Utiliser %s + Utiliser les serveurs + Pour recevoir + Ouvrir les modifications + Messages non distribués + Vous pouvez copier et réduire la taille du message pour l\'envoyer. + Quitter la discussion + Voir les conditions mises à jour + Navigation améliorée dans les discussions + Lorsque plusieurs opérateurs sont activés, aucun d\'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. + Le message est trop volumineux ! + Mobiles à distance + Modifier la liste + Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. + Transparence + Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. + Autoriser le signalement des messages aux modérateurs. + 1 an + Tous les signalements seront archivés pour vous. + Archiver tous les signalements ? + Archiver %d signalements ? + Modifier la suppression automatique des messages ? + Supprimer les messages de discussion de votre appareil. + par défaut (%s) + Bloquer ces membres pour tous ? + Désactiver la suppression automatique des messages ? + Désactiver la suppression des messages + Ne manquez pas les messages importants. + Recevoir une notification en cas de mention. + Vie privée et sécurité renforcées + Suppression plus rapide des groupes. + Envoi plus rapide des messages. + Pour moi + Archiver les rapports + Pour tous les modérateurs + Les membres peuvent signaler des messages aux modérateurs. + Groupes plus performants + Erreur lors de la lecture de la phase secrète de la base de données + Aider les administrateurs à modérer leurs groupes. + Tous les nouveaux messages de ces membres seront cachés ! + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index bd1875a788..3084b8569b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -1,5 +1,1487 @@ - Pretraži - Isključeno - \ No newline at end of file + Pretraži + Isključeno + Prekini + Prosledi do 20 poruka odjednom. + Sakriti kontakt i poruku + %1$d greska(e) datoteka:\n%2$s + %1$d datoteka se i dalje preuzima. + %1$d datoteka neuspešno preuzeta. + %1$d odstranjene datoteke. + %1$d datoteka nije preuzeta. + završen + %1$s želi da se poveže sa tobom putem + Prihvatiti + Pozivnica za grupu je istekla + Grupni link + Sakriti + Dobar dan! + Istorija nije poslana novim članovima. + Sakriti + 5 minuta + Ceo Link + Proslediti poruke… + Prekini menjanje adrese? + Ako se ne možeš sastati, pokaži QR kod u video pozivu ili podeli link. + Sakriti profil + a + b + prihvati poziv + Dodeliti dozvolu + Slušalice + POMOĆ + Grupa će biti obrisana za Vas – ovo ne može da se poništi! + Akcenat + Grupni linkovi + Madjarski i Turski UI + Kako koristiti markdown + Iz Galerije + 1 izveštaj + O SimpleX adresama + iznad, onda: + Prihvati zahtev za vezu? + Zdravo!\nPoveži se sa mnom pomoću SimpleX Razgovora: %s + Grupa neaktivna + U potpunosti decentralizovan - vidljivo samo za članove grupe. + Celo ime grupe: + pomoć + Celo Ime: + Prekini poziv + Prihvatiti + Francuski interfejs + Sakriti ekran aplikacije u nedavnim aplikacijama. + sati + 30 sekundi + Sakriti: + Sakriti + Prosledi poruke bez datoteka? + Kako da + Prihvatiti + %1$d poruke preskočene. + %1$d preskočena poruka(e) + 1 mesec + 1 nedelja + Pozivnica za grupu više nije validna. Pošiljalac je uklonio. + Grupa nije pronadjena! + Prihvati uslove + Grupa već postoji! + Prekini razgovor + Istorija + Dodeliti dozvolu(e) za uspostavljanje poziva + Kako radi + %1$s ČLANOVI + Upiši ime grupe: + Kako koristiti svoje servere + ICE serveri (jedan po liniji) + Prekini menjanje adrese + Nadji Radnu Površinu + Skriveno + %1$s poruke nisu prosledjene + Grupe + Kako se koristi + O SimpleX Razgovoru + %1$d neuspešno u dešifrovanje poruke. + grupni profil ažuriran + grupa odstranjena + Grupa + Ako izabereš da odbiješ pošiljalac NEĆE biti obavešten. + Skriveni razgovori + Grupa će biti obrisana za sve članove – ovo se ne može poništiti! + Domaćin + Kako utiče na bateriju + Kako pomaže privatnosti + Kako SimpleX radi + Grupni profil je uskladnjen na uredjajima korisnika, ne na serverima. + O operatorima + Skrivena šifra profila + 1 dan + Upiši ispravnu pristupnu frazu. + 1 minut + Prihvatiti + Dobro jutro! + Dodatno smanjena potrošnja baterije. + O SimpleX + prihvaćena pozivnica + poruka + Odstraniti i obavestiti kontakte + Odstraniti kontakt? + Odstraniti bazu podataka sa ovog uređaja + Odstraniti kontakt + Greška + Odstranjeno u + Šifra + Ažuriranje dostupno: %s + Napravi adresu + odstranjen kontakt + Dodaj poruku dobrodošlice + Poslano u: %s + Ažuriraj + Šifra profila + Administratori mogu da blokiraju. + Greška + Napravi jednokratnu poveznicu + Nalepiti + PODEŠAVANJE + Profilne slike + Razumeo + Odstranjeno + odstranjeno + Napraviti + PORUKE I DATOTEKE + Poruka + SERVERI + Odstraniti profil razgovora + administratori + Nasumično + greška + Greška: %1$s + Odstraniti %d poruke? + Odstraniti + Podešavanje + Ažuriraj + Odstraniti sve datoteke + Odstraniti profil razgovora? + Poruke + administrator + Odstranjeno u: %s + Oblikuj profilne slike + Aktivne veze + Dodaj na drugi uredjaj + Greška + Greška + Adresa + Greške pri potvrdi + Lista + Ime liste... + Odstraniti + Odstraniti nakon + Dodaj adresu na svoj profil da bi tvoji kontakti mogli da dele sa ostalima. Ažuriranje profila biće poslano tvojim kontaktima. + Dodaj kontakt + Dodaj listu + Prikazati šifru + Odstraniti + Promena adrese će biti prekinuta. Biće upotrebljena stara adresa. + Poslano direktno + Sve + Poslano u + poslano + Odstraniti profil razgovora za + Prihvati anonimno + Podeliti + Dodaj na listu + Pristup na servere pomoću SOCKS proxy na portu %d? Proxy mora da bude uključen pre omogućavanja ove opcije. + Prihvaćeni uslovi + Dodaj server + Dodaj unapred postavljene servere + Odstraniti adresu? + odstranjena grupa + Dodaj nalog + Odstraniti profil razgovora? + Napraviti + Odstraniti razgovor? + Odstranjena adresa + Odstranjena baza podataka + Arhiviraj i otpremi + Odstraniti razgovor + Ime profila: + Dodaj server skeniranjem QR koda. + Ažuriraj + Dodatni akcent + Odstraniti + Tema profila + Dodat serveri za poruke + Dodatni akcent 2 + Podešavanje + Podešavanje adrese + Dodaj tvoje članove tima u konverzaciju. + Dodaj prijatelja + Dodaj člana tima + Arhiva + poveži + Kamera + Više + Otključaj + Nezaštićeno + Nepoznati serveri + Bluetooth + Pozivi na zaključanom ekranu: + %d dana + Poveži automatski + Povezano + Greške + Preuzeto + povezivanje… + Kamera + %d mesec + Datoteka nije pronađena + %d poruke blokirane administratorom + %d izvještaji + poziv + Kamera nije dostupna + pozivanje… + %d min + %d sec + Preuzeti datoteke + blokirano administratorom + Vi + nepoznat status + Poveži se anonimno + greška u pozivu + Poveži + nepoznat format poruke + Povezano + %ds + %d sata(i) + Povezivanje + povezivanje… + Poveži + Poveži + povezano + povezano + Preuzimanje + Napredna podešavanja + Poziv u toku + POZIVI + Blokiraj članove grupe + Nepoznati serveri! + Datoteka + Datoteka + Uključiti zvuk + Kamera i mikrofon + vi: %1$s + Povezano sa telefonom + blokirano + %d poruka blokirano + povezivanje + Povezan telefon + VI + Zamućeno za bolju privatnost. + %d meseca(i) + Poziv završen + Poništiti + Osim ako vaš kontakt nije obrisao vezu ili je ova pozivnica već korišćena, moguće je da je u pitanju greška - molimo vas da je prijavite.\nDa biste se povezali, molimo vas da zamolite vaš kontakt da kreira novu pozivnicu i proverite da li imate stabilnu mrežnu konekciju. + povezivanje… + Uključiti zvuk + povezano + Povezano + Preuzimanje + Povezati direktno? + %d dan + %dh + %dm + %dmon + povezivanje… + nepročitano + poziv završen %1$s + poziv u toku + povezano direktno + uključeno + %d sat + podebljano + blokirano + Napredna podešavanja + blokirano %s + Blokiraj za sve + povezano + Poziv već završen! + Nepoznata greška + povezivanje + povezivanje (prihvaćeno) + Blokiraj člana za sve? + Blokiraj člana + Blokirano administratorom + Blokiraj člana? + Zamućeno + Otkači + %d minut(a) + Proveri ažuriranje + Stabilno + DATOTEKE + %s otpremljeno + Onemogućiti obavještenja + %s nije verifikovan + Sve poruke biće odstranjene - ovo ne može biti vraćeno! + Profil Chata + Onemogućiti + %d nedelja + - neobavezno obavestiti o obrisanim kontaktima.\n- imena profila sa razmacima.\n- i još mnogo toga!\" + Uvoženje arhive + Datoteke + Otpremljeno + %s je verifikovan + Konzola chata + Skenirati QR kod + Server + Onemogućiti + BAZA PODATAKA CHATA + onemogućeno + Greška pri uvoženju teme + Datoteke i medijski sadržaji su zabranjeni. + Ili nalepiti link arhive + Onemogućeno za sve grupe + Nestaje u + Datoteke i medijski sadržaji + svi članovi + Poruke koje nestaju + onemogućeno + Dozvoliti pozive? + %d sekunde(i) + Ili skenirati QR kod + Onemogućeno + Aplikacija + RAZGOVORI + Datoteke i medijski sadržaji su zabranjeni! + Poruke koje nestaju su zabranjene u ovom razgovoru. + Chat je zaustavljen + Datoteka sačuvana + Verzija aplikacije + Verzija aplikacije: v%s + Tema razgovora + %s sekunda(i) + Baza podataka Chata izvezena + Operator + Operacioni server + Poruke koje nestaju + Poruke koje nestaju su zabranjene. + Adresa servera + Datoteke i medijski sadržaji nisu dozvoljeni + Dozvoliti + Razgovor sa kreatorima + Razgovori + %d nedelja(e) + Računar + Direktne poruke između članova su zabranjene. + Direktne poruke između članova su zabranjene u ovoj grupi. + %s serveri + Dozvoliti + Direktne poruke + Onemogućeno za sve + Chat migriran! + %s, %s i %d članovi + Prekinuti vezu + Chat već postoji! + Baza podataka Chata odstranjena + Proveri ažuriranje + Chat je zaustavljen. Ako već koristiš ovu bazu podataka na drugom uređaju, trebala bi se vratiti pre nego što se pokrene chat. + Proveri poruke svakih 10 minuta + %s povezan + %s, %s i %s povezani + direktno + Chat + QR kod + Chat je pokrenut + Uvesti bazu podataka + BAZA PODATAKA CHATA + Chat je zaustavljen + %s, %s i %d ostali članovi povezani + Uvoz neuspešan + Razgovor će biti odstranjen za tebe - ovo se ne može vratiti! + Uvesti temu + Baza podataka Chata uvezena + %s (sadašnji) + Nestaje u: %s + Uvesti + Boje Chata + %s: %s + Ili uvesti arhiviranu datoteku + Razgovor će biti odstranjen za sve članove - ovo se ne može vratiti! + Direktne poruke između članova su zabranjene u ovim porukama. + Prekinuti vezu + %s preuzeto + Već ste povezani na %1$s. + Glasovne poruke nisu dozvoljenje + Greška u vezi + Test server + audio poziv (nije e2e šifrovan) + Novi razgovor + Sačuvati i obavestiti kontakt + Sačuvati i obavestiti članove grupe + tajna + video poziv (nije e2e šifrovan) + Video uključen + Audio isključeno + Audio uključeno + Crna + isključeno` + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Započeti razgovor + italic + Urediti + Sačuvati + Video + Blok + sekunde + Test serveri + Vaša podešavanja + Mikrofon + Ugao + Popraviti + Obezbeđeno + Veze + nova poruka + Email + Koristi SOCKS proxy? + Veza blokirana + Obaveštenja + Video + Prihvatili ste vezu + Očistiti + SimpleX adresa + SimpleX Logo + Prikazati: + UREĐAJ + Nova poruka + Sekundarni + Kontakti + Isključeno + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Omogućiti + Vaši serveri + Sačuvati + sek + Glasovne poruke + OK + Audio poziv + Video poziv + Koristi server + Adresa vašeg servera + Vaši SMP serveri + Vaši XFTP serveri + Obaveštenja i baterija + Prikazati + e2e šifrovano + Otvoriti + Omogući automatsko brisanje poruka? + šifrovanje ok za %s + Isključiti zvuk + SimpleX + Teme + Glasovne poruke + Audio/video pozivi su zabranjeni. + Otvoriti grupu + Svi profili + Započeti novi razgovor + Očistiti + Očistiti razgovor + Tvoj server + šifrovanje prihvaćeno za %s + Glasovne poruke su zabranjene. + veza %1$d + Video isključen + ništa + Veza + Omiljeno + Kontakti + Omiljen + Nikada + Veza + TEME + Audio/video pozivi + ne + šifrovanje ok + šifrovanje prihvaćeno + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Nazad + Vaša SimpleX adresa + Sačuvati i obavestiti kontakte + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + uređeno + Ne + Ne + Zadržite konverzaciju + SimpleX Adresa + Sačuvati + simplexmq: v%s (%2s) + EKSPERIMENTALNO + nikada + Očistiti + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + e2e šifrovan zvučni poziv + video poziv + Koristi %s + Koristi servere + Vaši kontakti + Ne + Započeti razgovor? + otvoriti + Nastaviti + Otvoriti + Pozadina + audio poziv + Audio & video pozivi + Nastaviti + Audio i video pozivi + Urediti + isključeno + Glasovne poruke su zabranjene u ovom razgovoru. + Isključiti zvuk + Zadržati + Popraviti + Očistiti razgovor? + Očistiti privatne beleške? + Nastaviti + arhivirani izveštaj + Uspešno instalirano + Skenirati telefonom + PING interval + Na čekanju + Poslane poruke + Bezbednostni kod + Poslati pitanja i ideje + Ponoviti uvoz + Trenutna obaveštenja! + Poslati Poruku + Trenutno + Ukloniti člana + Poslati + Sačuvano + Slika + jednokratna veza + Koristi nasumične pristupne podatke + Port + Korisničko ime + port %d + Sačuvati podešavanja? + Privatna obaveštenja + Dolazni audio poziv + bez e2e šifrovanja + Okrenuti kameru + odblokiran %s + uklonjena kontakt adresa + prihvatiti šifrovanje za %s… + uklonjeno + levo + Napustiti grupu + Uloga + Svetlo + %dn + Nevažeća veza + Statistika + Ponovo povezati + Veličina + Odstraniti sliku + propušten poziv + Odbijeni poziv + Poziv na čekanju + Poslana poruka + Anonimne grupe + Izabrati + Pozvati + arhivirani izveštaj od %s + nevažeći razgovor + Opis + Trenutna obaveštenja + Zaustaviti razgovor + Poruka koja nestaje + bez detalja + Skenirati kod + Pristupiti + Pristupiti grupi? + član %1$s promenjen u %2$s + Poruka dobrodošlice + Ukloniti sliku + Privatne beleške + Nevažeća veza + Dobrodošli %1$s! + Periodična obaveštenja + Otvoriti konzolu razgovora + pretraži + video + peer-to-peer + Anonimni režim štiti Vašu privatnost koristeći novi nasumični profil za svaki kontakt. + nedelje + Interna greška + Sačuvano od %s + sačuvano + pozvan + Sačuvana poruka + Obnoviti boje + Povezati telefon + Arhiviraj izveštaj? + Prijem + Izveštaj + U odgovoru na + Izveštaji + Beleške + Poslati živu poruku + Odstraniti veze na čekanju? + Skenirati / Nalepiti link + Neispravan bezbednostni kod! + odbijeni poziv + Pozvati + neaktivan + Sačuvati i ponovo povezati + k + Pristupiti grupi? + nevažeći format poruke + Uključiti + Odstraniti poruku? + uklonjena profilna slika + profilna slika + Tamno + Povezani telefoni + Pretplaćen + Pitati + Pozvati u grupu + Sistem + Sistem + Svetlo + Obnoviti boju + Svetlosni režim + Tamni režim + Pristupiti grupnoj konverzaciji + Adresa računara + Pristupiti Vašoj grupi? + Trenutna obaveštenja su onemogućena! + Zaustaviti datoteku + pristupiti kao %s + Informacije + Odgovoriti + Član neaktivan + SOCKS proxy podešavanje + SOCKS proxy + Propušten poziv + Odstraniti poruke nakon + Odstraniti poruke + levo + bezbednostni kod promenjen + Lokalno ime + (trenutno) + Pogledati uslove + Sistem + Tamno + Tamna tema + Povećati veličinu slova. + Detalji + Obnoviti + Obnoviti statistiku + Sistem + Poslati direktnu poruku za povezivanje + Slika + Na čekanju + SMP serveri + Upišite Vaše ime: + Otvoriti razgovor + pozvan %1$s + prihvatiti šifrovanje… + Slanje pomoću + Odstraniti redosled + Privatne beleške + nevažeći podaci + Zaustaviti + Popraviti vezu + Zaustaviti + poslati direktnu poruku + Priložiti + Sačuvati listu + Samouništenje + Pogrešna adresa računara + Kritična greška + uklonjeno %1$s + član + Početna uloga + posredan (%1$s) + Sačuvano od + Periodična obaveštenja su onemogućena! + Poruka dobrodošlice + Otvoriti pomoću %s + Ukloniti arhivu? + Izabrano %d + Dobrodošli! + Previše video snimaka! + Poslati + Obnoviti + Nevažeća veza! + Izabrati profil razgovora + Skenirati QR kod servera + Napredna mrežna podešavanja + SOCKS PROXY + Anonimni režim + broj PING + Obnoviti statistiku? + Pozvati članove + TCP veza + Informacije o serveru + Popraviti vezu? + Arhivirani kontakti + Arhivirati izveštaj + Skenirati QR kod sa radne površine + Napustiti razgovor? + Dolazni video poziv + Pozvati članove + Tamni režim boja + %dd + Povezane radne površine + Šta je novo + Ukloniti + pokušaji + Anonimno + Odstraniti profil + Video poslan + Sačuvati servere + Sačuvati servere? + Ukloniti člana + bez teksta + Poruka dobrodošlice je preduga + Arhiviraj bazu podataka + Periodično + Ukloniti + ČLAN + Pristupanje grupi + SMP server + Pozvati u razgovor + Odblokirati + Italijanski interfejs + Datoteka: %s + Pristupiti anonimno + Napustiti grupu? + Uređaji + Izabrati + Poslati potvrde + Popraviti vezu? + Napustiti + Napustiti razgovor + Izabrati kontakte + Poslati direktnu poruku + Ukloniti člana? + Ponoviti + Veličina teksta + Videa i datoteke do 1gb + Nekompatibilna verzija + Ponoviti preuzimanje + Zaustaviti razgovor? + Odstraniti vezu + Kreirano u: %s + vlasnici + Dovršiti migraciju na drugom uređaju. + Drugo + Kreirano + Ostale greške + Ponoviti + Šifrovati bazu podataka? + %s i %s + vlasnik + Kopirati + Predati + Ostali XFTP serveri + Napraviti SimpleX adresu + Šifrovati + Kreirano u + Otvoriti uslove + Kontrolisanje vaše mreže + Kopirano + Poruka je prevelika + Otpremiti datoteku + Napraviti datoteku + Napraviti tajnu grupu + kreator + Otkriti + Napraviti listu + Greška pri kopiranju + %s i %s su povezani + Napraviti grupu + Napraviti tajnu grupu + Napokon ih imamo! 🚀 + Dovršiti migraciju + Ništa nije izabrano + Konverzacija odstranjena! + Ostali SMP serveri + Verzija jezgra: v%s + Šifrovati bazu podataka + Odstraniti vezu? + Pretplata ignorisana + Napraviti profil razgovora + Napraviti profil + Greške u pretplati + Pasent + Odstraniti listu? + Za primanje + drugo + Napraviti profil + Vi odlučujete ko se može povezati. + Odstraniti grupu? + Obnoviti + Prebaciti + Beta + Urediti sliku + Mala grupa (max 20) + Primljeno u + otkazano %s + primljena potvrda… + Bolje grupe + Drugi razlog + Arhivirati kontakte za kasniji razgovor. + Primljene poruke + Bolja sigurnost ✅ + Napraviti vezu + Primljena poruka + Dodirnuti za Povezivanje + Dodirnuti dugme + Otvoriti port u firewallu + Primljeno u: %s + Pomoću pretraživača + Podeliti adresu javno + Preskočiti ovu verziju + Podseti kasnije + Povezivanje poziva + Odgovoriti na poziv + Mrežna veza + Decentralizacija mreže + Biznis razgovori + Završeno + Proširiti + Prikazati QR kod + Preuzeti datoteku + Napraviti grupni link + Biznisi + Bolje korisničko iskustvo + Spora funkcija + Urediti profil grupe + Dodirnuti za aktivaciju profila. + Tema aplikacije + Dodirnuti za pristup + Dodirnuti za anonimni pristup + Podeliti adresu + Rep + Bolji pozivi + Biznis adresa + Primljena poruka + završiti + Proširiti selekciju uloga + Otvaranje baze podataka… + Obnoviti + Zvučnik isključen + obojen + Podeli vezu + Spam + Zvučnik + Spam + Razgovori nisu pronađeni + Zvučnik uključen + Bezbednije grupe + Odbiti + Odbiti + Razgovori nisu u listi %s. + Previše slika! + autor + Izvesti temu + Žive poruke + Website + dani + Prosleđivanje %1$s poruka + Kada je dostupno + Proslediti i sačuvati poruke + Prosleđeno + Server za prosleđivanje: %1$s\nGreška: %2$s + Označiti da je pročitano + Server za prosleđivanje : %1$s\nGreška odredišnog servera: %2$s + Verifikovati veze + Primeniti na + Zaustavljanje razgovora + Nalepiti link koji ste primili + Oceniti aplikaciju + Nema nepročitanih razgovora + Izveštaj će biti arhiviran za Vas. + Sačuvati neiskorišćenu pozivnicu? + Glasovna poruka… + Koristiti trenutni profil + Nalepiti link + Nalepiti link za povezivanje! + ŽIVO + Zvezdica na GitHubu + Razmera + Verifikovati bezbednost veze + Trenutni profil + Odstraniti grupu + prosleđeno + Tekst poruke + Prosleđeno od + Učitavanje razgovora… + Proslediti %1$s poruku(e)? + Bezbednost veze + Kada je aplikacija pokrenuta + Proslediti + Velika datoteka! + Primeniti + Poruka će biti odstranjena - ovo se ne može poništiti! + Poruka je prevelika! + Učitavanje datoteke + Glasovna poruka + Glasovna poruka (%1$s) + Naučiti više + Označiti da je verifikovano + Koristiti razgovor + Označiti da nije pročitano + Verifikovati vezu + zahtev za povezivanje + Dodirnuti za skeniranje + Nepoznata greška u bazi podataka: %s + Primljene poruke + duplikati + da + Migracija završena + Da + Omogućiti flux + Greška: %s + Greška u zaustavljanju razgovora + Doprinesite + moderirao %s + Trenutan Pin kod + Čekanje na video + Čekanje na dokument + Neophodno + Uvek + Preuzimanje %s (%s) + Ignorisati + Novi Pin kod + Pin kod aplikacije + Omogućiti pin kod za samouništenje + Pin kod za samouništenje + Isključiti + Moderirano u + Vidljiva istorija + Prekinuta veza iz razloga: %s + Mobilna + Poruke poslane + Za sve + Omogući evidenciju + Odmah + greška u slanju + Odstraniti server + blokirali ste %s + Kvantno otporno šifrovanje + Ne praviti adresu + Omogućiti svim grupama + Ne prikazivati ponovo + minuti + meseci + Pregled + Uneti Pin kod + Sesija aplikacije + Prikazati samo kontakt + nepoznato + Promeniti redosled + promena adrese za %s… + Greška u promeni profila! + Koristiti novi anoniman profil + veza uspostavljena + Promeniti listu + Slušalica + Isključiti? + Ovaj uređaj + Omogućiti samouništenje + Uvek uključeno + Odstraniti izveštaj + Poslana slika + Potvrditi + Još nekoliko stvari + Povezati pomoću jednokratnog linka? + Pin kod + Vi i Vaš kontakt, možete da šaljete poruke koje nestaju. + Zvukovi u pozivu + standardno od kraja-do-kraja šifrovanje + moderator + uvek + Smanjiti upotrebu baterije + Proxied(posrednički) serveri + Novi server + Prikazati procente + Napustiti bez čuvanja + POKRENUTI RAZGOVOR + Odblokirati člana za sve? + Priprema za preuzimanje + Proxied(posredovan) + Greška u dešifrovanju + Prikazati pregled + Migracije: %s + Ova grupa više ne postoji. + promenili ste ulogu %s u %s + posmatrač + Status poruke + Status datoteke + podrazumevano (%s) + Persijski UI + Statistika servera ce biti obnovljena - ovo se ne može poništiti! + Promeniti pin kod + Čekanje na sliku + Čekanje na sliku + Čekanje na video + Za početak novog razgovora + Omogućiti svima + Greška u pokretanju razgovora + uloga promenjena iz %s u %s + Promeniti ulogu grupe? + Odstraniti datoteku + SimpleX Tim + moderirano + Operator servera blokira vezu:\n%1$s. + Promeniti ulogu + Ime ovog uređaja + Dodirnuti za početak novog razgovora + pozvani ste u grupu + Slika sačuvana u Galeriji + vaša uloga je promenjena u %s + promenili ste adresu + Kontakt je odstranjen. + Japanski i Portugalski UI + Pin kod za samouništenje + Pronađite i pristupite grupama + Odstraniti za mene + Greška u dekodiranju + Prekinuta veza + Potvrditi brisanje kontakta? + Greška u promeni profila + Pin kod za samouništenje promenjen! + Pin kod za samouništenje omogućen! + Uvesti bazu podataka razgovora? + Odblokirati člana + Odblokirati člana? + Poslati greške + Promeniti + Čekanje na računar… + Ne dozvoliti + omogućeno + Vaš nasumičan profil + Molimo pokušajte kasnije. + Pošaljite nam email + Moderirano u: %s + Potvrditi otpremanje + Popuniti + Greške u dešifrovanju + Ukupno + Potvrditi unapređenje baze podataka + Pozvani ste u grupu + promenili ste vašu ulogu na %s + Potvrda je onemogućena + Imunitet na spam + duplikat poruke + Da + Greška u bazi podataka + podrazumevano (%s) + 1 godina + uklonili ste %1$s + napustili ste + promenili ste adresu za %s + Odblokirati za sve + Status poruke: %s + Status datoteke: %s + TCP veza тајм-аут + Poslana poruka + Omogućeno za kontakt + Omogućeno za Vas + Vi i Vaš kontakt, možete da šaljete glasovne poruke. + Omogućeno za + Nadograditi aplikaciju automatski + Litvanski UI + Prikazati informacije za + WiFi + Uskoro! + istekao + Vi i Vaš kontakt, možete da obavljate pozive. + Kontakt već postoji + Pogledati bezbednostni kod + Podeliti jednokratnu veza + Proslediti poruku… + Adresa ili jednokratna veza? + Omogućiti pristup kameri + Nevažeći QR kod + Jednokratna pozivnica + Uneti poruku dobrodošlice… + Čuvanje %1$s poruka + Prenosna izolacija + Uneti poruku dobrodošlice… (neobavezno) + Koncept poruke + Neispravan pin kod + čeka se potvrda… + Uneti server ručno + kontakt nema e2e šifrovanje + Kontakt odstranjen! + Nevažeći QR kod + Procena bezbednosti + Uporediti datoteku + Kontakt i sve poruke biće odstranjene - ovo ne može biti vraćeno! + Datoteka će biti odstranjena sa servera. + Opozvati + Poništiti pregled slike + %s u %s + Poništiti živu poruku + Jednokratna pozivnica + Nevažeća adresa server! + Sačuvani WebRTC ICE biće uklonjeni. + Unos pin koda + Prosleđena poruka + Ništa za prosleđivanje + Pregledati kasnije + kontakt ima e2e šifrovanje + Zaključati nakon + Skriven kontakt: + Poništiti pregled datoteke + Omogućiti potvrde? + Omogućiti potvrde za grupe? + Transparentnost + Pregled uslova + Poništiti migraciju + Opozvati datoteku + Opozvati datoteku? + Živa poruka! + Greške u preuzimanju + Sačuvati poruku dobrodošlice? + Nekompatibilno! + Pripremanje otpremanja + Sačuvati šifru profila + WebRTC ICE serveri + Pregled obaveštenja + povezivanje poziva… + Pin kod nije promenjen! + Izvesti bazu podataka + Istekla pozivnica! + pozivnica u grupu %1$s + Nekompatibilna verzija baze podataka + Status mreže + Za slanje + Koncept poruke + Prenosna izolacija + Uneti ime ovog uređaja… + Prenosne sesije + Neuspešno preuzimanje + Zaustaviti deljenje adrese? + Greška u primanju datoteke + Greška u čuvanju datoteke + Podeliti profil + Zaustaviti deljenje + Prikazati poslednje poruke + Odstraniti do 20 poruka odjednom. + %s prekinuta veza]]> + Ovo je Vaša vlastita SimpleX adresa! + Očistiti verifikaciju + Greška u otvaranju pretraživača + Nema razgovora + Povezati se sa %1$s? + želi da se poveze sa Vama! + Prikazati status poruke + Veza nije spremna. + Povežite se brže sa svojim prijateljima. + %s prekinuta veza]]> + Datoteka je odstranjena ill je link neispravan + Ukupno poslano + Za društvene mreže + %s nedostaje]]> + Preuzimanje arhive + Ukupno primljeno + %s je neaktivan]]> + Ime kontakta + Greška pri isporuci poruke + Poruke će biti označene za odstranjivanje. Primaoci će moći da otkriju te poruke. + Odstraniti datoteke i medijski sadržaj? + Primanje datoteke biće zaustavljeno. + Datoteka će biti primljena kada vaš kontakt bude na mreži, molimo sačekajte ili proverite kasnije! + Izabrati datoteku + Migriranje + Migriraj uređaj + Zaustaviti slanje datoteke? + Nije izabran nijedan razgovor + promena adrese… + %s je zauzet]]> + promena adrese… + Do 100 poslednjih poruka se šalje novim članovima. + Bolje poruke + Poruke će biti odstranjene - ovo se ne može poništiti! + Slanje datoteke biće zaustavljeno. + Zaustaviti primanje datoteke? + Greška u datoteci + Odstraniti bez obaveštenja + Greška u otpremanju + Ukupno grešaka + Detaljna statistika + Greška prilikom odstranjivanja + Ponoviti otpremanje + Koristiti sa računara + Povezati sa računarom + Povezano sa računarom + Dozvoliti glasovne poruke? + Promeniti prijemnu adresu? + Reakcije na poruku + Dozvoliti reakcije na poruku. + Reakcije na poruku + Promeniti prijemnu adresu + Nema poruka + Nema istorije + Reakcije na poruku su zabranjene. + Bezbedan redosled + Napraviti redosled + Koristiti SOCKS proxy + Greška pri šifrovanju baze podataka + Koristiti za poruke + Greška pri kreiranju profila! + Greška pri učitavanju detalja + Greška pri čuvanju XFTP servera + Otpremljene datoteke + Otvoriti podešavanja servera + Greška pri odstranjivanju privatnih beleški + Greška pri prosleđivanju poruka + Poruke su odstranjene nakon što ste ih odabrali. + NE koristiti privatno usmeravanje. + Kada je IP skrivena + Izgled + Pozvati prijatelje + Potvrditi šifru + Potvrditi pin kod + Odrediti pin kod + Ako unesete Vaš pin kod za samouništenje prilikom otvaranja aplikacije: + Onemogućiti potvrde? + povezivanje (uvedeno) + Uneti šifru u pretragu + Greška pri učitavanju XFTP servera + Greška pri odstranjivanju grupe + Nije moguće poslati poruku + Poslati poruku koja nestaje + Serveri za poruke + Pročitati više + XFTP server + Greška pri pravljenju adrese + neovlašćeno slanje + Verifikovati bezbednostni kod + Dugme zatvoriti + Greška pri pristupanju grupi + Opcije za programere + Alati za programere + Preference chata + Potvrdite da su mrežna podešavanja za ovaj uređaj ispravna. + Sadržaj krši uslove korišćenja + Nije moguće primiti datoteku + Greška pri kreiranju poruke + Otvoriti podešavanja aplikacije + Omogućiti SimpleX Zaključavanje + Podeliti datoteku… + Prilagođeno vreme + Povezani serveri + Vaš profil će biti poslan kontaktu od koga ste primili ovu vezu. + Pokrenuti periodično + Preskočene poruke + Pin kod postavljen! + Svi podaci u aplikaciji su odstranjeni. + Pin kod aplikacije je zamenjen pin kodom za samouništenje. + POTPORI SIMPLEX CHAT + Oblik poruke + IKONA APLIKACIJE + Pristupna fraza baze podataka + Odrediti pristupnu frazu + Baza podataka će biti šifrovana. + Neispravna pristupna fraza! + Uneti pristupnu frazu… + Uslovi korišćenja + Greška pri dodavanju servera + Primljen odgovor + Uvećanje + Dozvoliti slanje glasovnih poruka Vašim kontaktima. + Dozvoliti glasovne poruke samo ako ih Vaš kontakt dozvoljava. + Dozvoli svojim kontaktima dodavanje reakcija na poruke. + Dozvoliti slanje direktnih poruka članovima. + Zabraniti slanje direktnih poruka članovima. + Dozvoliti slanje glasovnih poruka. + Dozvoliti slanje SimpleX veza. + SimpleX veze su zabranjene. + Napraviti grupu koristeći nasumičan profil. + Koristiti aplikaciju jednom rukom. + Potvrditi mrežna podešavanja + Greška pri čuvanju baze podataka + Poslan odgovor + Isključiti zvuk svima + Greška pri čuvanju proxy + Ili deliti privatno + Greška pri čuvanju korisničke šifre + Vaše preference + Prethodno povezani serveri + Komadi su preuzeti + Onemogućiti automatsko brisanje poruka? + Hvala Vam što ste instalirali SimpleX Chat! + Neki serveri nisu prošli test: + Mrežni operator + Možete pokušati ponovo. + Poslano putem proxy + pozvan za povezivanje + pomoću %1$s + Greška pri učitavanju SMP servera + Greška pri čuvanju SMP servera + Greška pri promeni adrese + Pristupnu frazu je potrebna + Spomenuti članove 👋 + Bolja privatnost i bezbednost + Ne propustiti bitne poruke. + prilagođen + Otkriti pomoću lokalne mreže + Uneti pristupnu frazu + Proverite internet vezu i pokušajte ponovo + Bez Informacija, pokušajte ponovo da učitate + Komadi su odstranjeni + SimpleX Chat pozivi + XFTP serveri + Mreža i serveri + Podesite ICE servere + Koristiti direktnu internet vezu? + Instalirati ažuriranje + Vaši pozivi + Omogućiti zaključavanje + ID Baze podataka + Zapis ažuriran u + Koristiti za datoteke + Optimizacija baterije je aktivna, isključeni su usluga u pozadini i periodični zahtevi za nove poruke. Možete ih ponovo omogućiti putem podešavanja. + Potvrda identiteta + Test servera nije uspeo! + Greška pri izvoženju baze podataka razgovora + Vremensko ograničenje protokola + Greška pri slanju poruke + Greška pri odstranjivanju kontakta + Napraviti jednokratnu pozivnicu + Markdown pomoć + Dozvoliti slanje datoteka i medijskog sadržaji. + Pin kod promenjen! + ID Baze podataka: %d + SimpleX veze + Dodani serveri za medije i datoteke + Vas profil %1$s će biti deljen. + primljen odgovor… + Mrežni operatori + Privatno usmeravanje + Nevažeća putanja datoteke + Moderirati + %1$d poruke moderirane %2$s + Poruke od %s biće prikazane! + Pristupna fraza baze podataka i izvoz + Koristite privatno usmeravanje sa nepoznatim serverima. + Intenzivno + Poruka će biti označena za ostranjivanje. Primaoci će moći da otkriju ovu poruku. + Privatnost i bezbednost + SimpleX Chat poruke + Možete pokušati ponovo. + Greška pri čuvanju podešavanja + Postavi poruku koja će biti prikazana novim članovima! + Greška pri čuvanju servera + Član će biti uklonjen iz razgovora – ovo se ne može poništiti! + ažuriran profil + povezivanje (najavljeno) + Greška u promeni uloge + Prilagođena tema + Pronaći razgovore brže + Vaša privatnost + Bivši član %1$s + Vremensko ograničenje protokola po KB + Blago + Podeliti medije… + NE šaljite poruke direktno, čak i ako vaš ili odredišni serveri ne podržavaju privatno usmeravanje. + Promeniti pin kod za samouništenje + Ažurirati pristupna frazu baze podataka + Za mene + Podeliti poruku… + Arhivirati izveštaje + SimpleX veze nisu dozvoljene + Promeniti automatsko brisanje poruka? + Glasovne poruke su zabranjene! + Ili prikazati ovaj kod + Koristiti za nove konekcije + Vaši ICE serveri + pokretanje… + Otvoriti podešavanje + Za odvijanje poziva, dozvoliti upotrebu mikrofona. Završite razgovor i pokušajte ponovo. + Baza podataka je šifrovana! + Zapis ažuriran u: %s + Član će biti uklonjen iz grupe – ovo se ne može poništiti! + Pozivi zabranjeni! + \nDostupno u v5.1 + SimpleX veze + Odstraniti za sve + Dozvoliti slanje poruka koje nestaju. + Pin kod aplikacije + Za sakrivanje neželjenih poruka. + Greška pri čuvanju podešavanja + Nevažeće ime! + Komadi su otpremljeni + Greške u otpremanju + Uvek koristiti privatno usmeravanje. + Markdown u porukama + Kada je omogućeno više od jednog operatera, nijedan od njih nema metapodatke da nauči ko komunicira sa kim. + Dozvoliti vašim kontaktima da vas zovu. + Prijem poruke + Greška pri izvoženju baze podataka razgovora + Možete učiniti vidljivim vašim SimpleX kontaktima putem Podešavanja. + Greška pri slanju pozivnice + Preskočiti pozivanje članova + Za privatno usmeravanje + Dodatni sekundarni + Pokretanje iz %s. + Vaši ICE serveri + Koristiti nasumičnu pristupnu frazu + Onemogućiti SimpleX Zaključavanje + Ponovo povezati server? + Nema primljenih niti poslanih datoteka + neispravan hash poruke + Neispravan ID poruke + Ponovo povezati servere? + Prikazati kontakt i poruku + Prikazati opcije za programere + Neispravan hash poruke + Ponovo povezati server kako biste prisilili dostavu poruke. To koristi dodatni saobraćaj. + neispravan ID poruke + Konfigurisani XFTP serveri + Upozorenje pri isporuci poruke + Povezati se pomoću linka / QR koda + Već imate chat profil sa istim prikaznim imenom. Molimo vas da odaberete drugo ime. + Greška u autentifikaciji + Unapređena konfiguracija servera + %1$d ostale greška datoteke(a). + Autentifikacija nije dostupna + Konfigurisani SMP serveri + Automatsko prihvatanje + Sačuvati preference? + Za pozive je potreban podrazumevani veb pretraživač. Molimo vas da konfigurišete podrazumevani pretraživač u sistemu i podelite više informacija sa programerima. + odblokirali ste %s + Vi ste posmatrač. + Unapređena privatnost i bezbednost + Migriraj na drugi uređaj pomoću QR koda. + odbijeno + Nova pristupna fraza… + Automatsko prihvatanje zahteva za kontakt + Samo Vaši kontakti mogu slati poruke koje nestaju. + Samo Vaši kontakti mogu slati glasovne poruke. + Vi dozvoljavate + Otvoriti fasciklu baze podataka + Sačuvati i ažurirati grupni profil + Uz smanjenu potrošnju baterije. + (skenirati ili nalepiti iz memorije) + Greška u vezi (AUTH) + Ažurirajte aplikaciju i kontaktirajte programere. + Tokom uvoza došlo je do nekih nefatalnih grešaka: + Sačuvati pristupnu frazu u podešavanjima + Poslali ste grupnu pozivnicu + kvantno otporna e2e šifrovanju + Samo Vaši kontakti mogu upućivati pozive. + Ne slati istoriju novim članovima. + Članovi mogu da šalju glasovne poruke. + Uz smanjenu potrošnju baterije. + Greška pri preuzimanju arhive + Svi razgovori biće uklonjeni sa liste %s, a lista odstranjena + Ovaj QR kod nije link! + Već se povezujete! + Migriraj na drugi uređaj + Otvoriti promene + Decentralizovano + Nema informacija o prijem + Povezati se pomoću linka + Zadržati Vaše konekcije + Migriraj ovde + Povezati se pomoću linka? + Instaliraj SimpleX Chat za terminal + Koristiti web port + Samo Vi možete da upućujete pozive. + Otvoriti lokaciju datoteke + odbijeno + Sačuvati grupni profil + Samo vi možete slati poruke koje nestaju. + Uporedite sigurnosne kodove sa vašim kontaktima. + Automatsko prihvatanje slika + Naslov + Neispravna pristupna fraza baze podataka + Migriraj sa drugog uređaja + Samo vi možete slati glasovne poruke + Samo vi možete stavljati reakcije na poruke. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 531e29de20..1a1ca0e8a6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1,6 +1,6 @@ - %1$d üzenet visszafejtése sikertelen. + Nem sikerült visszafejteni %1$d üzenetet. %1$d üzenet kihagyva. %1$d üzenet kihagyva %1$s TAG @@ -10,197 +10,196 @@ 5 perc 1 perc A SimpleX-címről - Címváltoztatás megszakítása? + Megszakítja a cím módosítását? Megszakítás 30 másodperc - Egyszer használható meghívó-hivatkozás - %1$s szeretne kapcsolatba lépni Önnel ezen keresztül: - SimpleX Chat névjegye + Egyszer használható meghívó + %1$s szeretne kapcsolatba lépni Önnel a következőn keresztül: + A SimpleX Chat névjegye 1 nap - Címváltoztatás megszakítása + Cím módosításának megszakítása A SimpleXről - Kiemelés + Kiemelőszín fogadott hívás - Hozzáférés a kiszolgálókhoz SOCKS proxyn keresztül a(z) %d porton? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. + Hozzáférés a kiszolgálókhoz SOCKS-proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. Elfogadás Elfogadás gombra fent, majd: Elfogadás inkognitóban - Kapcsolatkérés elfogadása? + Elfogadja a meghívási kérést? Elfogadás Elfogadás - Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés elküldésre kerül az ismerősök számára. - További kiemelés + Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára. + További kiemelőszín híváshiba Csoporttagok letiltása Hitelesítés - Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik. + Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik. %s visszavonva Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva ebben a csevegésben. - Az összes ismerőséhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> + Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> hivatkozás előnézetének visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. - Az Android Keystore-t a jelmondat biztonságos tárolására használják - lehetővé teszi az értesítési szolgáltatás működését. - Hibás az üzenet hasító értéke + Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. + Érvénytelen az üzenet hasítóértéke Háttér - Megjegyzés: az üzenet- és fájlközvetítő-kiszolgálók SOCKS proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> + Megjegyzés: az üzenet- és fájltovábbító kiszolgálók SOCKS-proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél. + A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). - Egy új véletlenszerű profil lesz megosztva. - A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. - Az alkalmazás build száma: %s + Egy új, véletlenszerű profil lesz megosztva. + A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. + Az alkalmazás összeállítási száma: %s Hang- és videóhívások Speciális hálózati beállítások - A hangüzenetek küldése engedélyezve van az ismerősei számára. + A hangüzenetek küldése engedélyezve van a partnerei számára. Hang- és videóhívások Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). Hívás fogadása - Az eltűnő üzenetek küldésének engedélyezése az ismerősei számára. + Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. Kapcsolódás folyamatban! Nem lehet fogadni a fájlt Hitelesítés elérhetetlen - Alkalmazás verzió + Az alkalmazás verziója Üdvözlőüzenet hozzáadása titkosítás elfogadása %s számára… - " -\nElérhető a v5.1-ben" + \nElérhető az v5.1-es kiadásban Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok - Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. Hívás befejeződött HÍVÁSOK és további %d esemény Cím - Csatlakozás folyamatban! + A csatlakozás folyamatban van a csoporthoz! Automatikus elfogadás - A háttérszolgáltatás mindig fut - az értesítések megjelennek, amint az üzenetek elérhetővé válnak. + A háttérszolgáltatás mindig fut – az értesítések megjelennek, amint az üzenetek elérhetővé válnak. Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) Mindkét fél küldhet hangüzeneteket. Téves üzenet ID - Az üzenetreakciók küldése engedélyezve van az ismerősei számára. + A reakciók hozzáadása az üzenetekhez engedélyezve van a partnerei számára. A hangüzenetek küldése engedélyezve van. - Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Vissza - Kikapcsolható a beállításokban – az értesítések továbbra is megjelenítésre kerülnek amíg az alkalmazás fut.]]> + Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. Hívások a zárolási képernyőn: titkosítás elfogadása… - Nem lehet meghívni az ismerőst! + Nem lehet meghívni a partnert! téves üzenet ID - Kapcsolatkérések automatikus elfogadása - Megjegyzés: NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti.]]> + Meghívási kérések automatikus elfogadása + Megjegyzés: NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> hívás… - További másodlagos + További másodlagos szín Hozzáadás egy másik eszközhöz - Az üzenetreakciók küldése engedélyezve van. + A reakciók hozzáadása az üzenetekhez engedélyezve van. Fájlelőnézet visszavonása Az összes csoporttag kapcsolatban marad. - Több akkumulátort használ! Az alkalmazás mindig fut a háttérben - az értesítések azonnal megjelennek.]]> + Több akkumulátort használ! Az alkalmazás mindig fut a háttérben – az értesítések azonnal megjelennek.]]> Letiltás adminisztrátor Képelőnézet visszavonása - A jelkód megadása után az összes adat törlésre kerül. + A jelkód megadása után az összes adat törölve lesz. Felkérték a videó fogadására Letiltás - Még néhány dolog + Néhány további dolog Hitelesítés visszavonva A fájlok- és a médiatartalmak küldése engedélyezve van. - Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza! hanghívás félkövér - Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal. - Arab, bulgár, finn, héber, thai és ukrán - köszönet a felhasználóknak és a Weblate-nek. - Hangüzenetek engedélyezése? - Mindig használjon közvetítő-kiszolgálót + Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. + Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. + Engedélyezi a hangüzeneteket? + Mindig használjon továbbítókiszolgálót mindig A hívás már befejeződött! Engedélyezés - Az összes ismerősével kapcsolatban marad. + Az összes partnerével kapcsolatban marad. Élő csevegési üzenet visszavonása - Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra) + Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) Hang- és videóhívások - hibás az üzenet hasító értéke + érvénytelen az üzenet hasítóértéke Mindig fut - Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatás után - lehetővé teszi az értesítések fogadását. + Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi az értesítések fogadását. Az összes alkalmazásadat törölve. Legjobb akkumulátoridő. Csak akkor kap értesítéseket, amikor az alkalmazás meg van nyitva. (NINCS háttérszolgáltatás.)]]> Megjelenés - Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek rendszeres lekérdezését. A beállításokban újraengedélyezheti. - Biztosan letiltja? + Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. Ezt a beállításokban újraengedélyezheti. + Letiltja a tagot? %1$s hívása befejeződött Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> szerző - Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra) + Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra) Mégse - Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem indul el + Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem fog elindulni Továbbfejlesztett üzenetek - A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra. + A cím módosítása meg fog szakadni. A régi fogadási cím lesz használva. Engedélyezés - Hibás számítógép cím + Érvénytelen számítógépcím Profil hozzáadása - Csatolás + Mellékelés Alkalmazás jelkód Felkérték a kép fogadására Kamera Nem érhető el a Keystore az adatbázis jelszavának mentéséhez hívás folyamatban Képek automatikus elfogadása - A hívások kezdeményezése engedélyezve van az ismerősei számára. + A hívások kezdeményezése engedélyezve van a partnerei számára. ALKALMAZÁSIKON Kiszolgáló hozzáadása QR-kód beolvasásával. Az eltűnő üzenetek küldése engedélyezve van. - Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Hang kikapcsolva A közvetlen üzenetek küldése a tagok között engedélyezve van. ALKALMAZÁS Hívás folyamatban - Mindkét fél küldhet üzenetreakciókat. + Mindkét fél hozzáadhat az üzenetekhez reakciókat. Mindkét fél tud hívásokat kezdeményezni. Sikertelen hitelesítés - Az összes %s által írt új üzenet elrejtésre kerül! - Alkalmazás verzió: v%s - A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + %s összes új üzenete el lesz rejtve! + Az alkalmazás verziója: v%s + A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva hanghívás (nem e2e titkosított) letiltva - Adatbázis-jelmondat megváltoztatása? + Módosítja az adatbázis jelmondatát? kapcsolódva - Jelkód megváltoztatása - %s szerepkörét megváltoztatta erre: %s - A fogadó cím megváltoztatása - Változtatás + Jelkód módosítása + a következőre módosította %s szerepkörét: „%s” + Fogadási cím módosítása + Módosítás Jelkód megerősítése Jelszó megerősítése - Csoport szerepkör megváltoztatása? - Zárolási mód megváltoztatása + Módosítja a tag szerepkörét? + Zárolási mód módosítása Kapcsolódott kapcsolódott Kapcsolódás kapcsolódott Társított hordozható eszköz kapcsolódva - Szerepkör megváltoztatása + Szerepkör módosítása Kapcsolódva Hitelesítőadatok megerősítése - Megváltoztatja a fogadó címet? - cím megváltoztatva - Önmegsemmisítő mód megváltoztatása - megváltoztatta az Ön szerepkörét erre: %s + Módosítja a fogadási címet? + módosította a címet az Ön számára + Önmegsemmisítő-mód módosítása + a következőre módosította az Ön szerepkörét: „%s” Kapcsolódás - Közvetlen kapcsolódás? + Közvetlenül kapcsolódik? Kapcsolódás közvetlenül kapcsolódott kapcsolat %1$d - az ismerős e2e titkosítással rendelkezik + a partner e2e titkosítással rendelkezik Csoport létrehozása véletlenszerű profillal. - Az ismerős és az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! - Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. - Kapcsolódás egyszer használható meghívó-hivatkozással? + A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! + A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. + Kapcsolódik az egyszer használható meghívóval? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) Csak név @@ -208,40 +207,40 @@ Cím létrehozása Másolás Folytatás - Kapcsolódás egy hivatkozáson keresztül? - Az ismerős már létezik + Kapcsolódik egy hivatkozáson keresztül? + A partner már létezik Fő verzió: v%s - Ismerős ellenőrizve - Kapcsolódás saját magához? + Partner ellenőrizve + Kapcsolódik saját magához? Kimásolva a vágólapra - Kapcsolatkérés elküldve! + Meghívási kérés elküldve! Kapcsolódás a számítógéphez Kapcsolat - Név helyesbítése erre: %s? + Helyesbíti a nevet a következőre: %s? Időtúllépés kapcsolódáskor - Kapcsolódás a következővel: %1$s? + Kapcsolódik vele: %1$s? Létrehozás - Ismerős beállításai + Partnerbeállítások Kapcsolat Kapcsolat megszakítva kapcsolat létrehozva - az ismerősnek nincs e2e titkosítása - Ismerős engedélyezi + a partner nem rendelkezik e2e titkosítással + Partner engedélyezi Rejtett név: Társítás számítógéppel Szövegkörnyezeti ikon Kapcsolódás egy hivatkozáson keresztül - Ismerősök + Partnerek Kapcsolódási hiba - Az ismerős még nem kapcsolódott! - - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (20 tagig).\n- gyorsabb és stabilabb. - Hozzájárulás + A partnere még nem kapcsolódott! + - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. + Közreműködés kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása - törölt ismerős - Csoporttag üzenetének törlése? + törölt partner + Törli a tag üzenetét? A csevegés fut - Egyszer használható meghívó-hivatkozás létrehozása + Egyszer használható meghívó létrehozása Törlés Új üzenetek ellenőrzése 10 percenként, legfeljebb 1 percen keresztül Adatbázis törlése @@ -249,11 +248,11 @@ Csevegési profil Profil létrehozása Társított számítógép - Törölve ekkor: %s - Törölve ekkor: + Törölve: %s + Törölve Kínai és spanyol kezelőfelület - Nem lehet meghívni az ismerősöket! - A csevegés leállt + Nem lehet meghívni a partnereket! + A csevegés megállt Sötét Profil létrehozása törölt csoport @@ -272,15 +271,15 @@ Jelenleg támogatott legnagyobb fájl méret: %1$s. Fájl törlése Hamarosan! - cím megváltoztatása nála: %s … + cím módosítása %s számára… Csevegési adatbázis importálva Üzenetek törlése Kiürítés Bezárás gomb - A csevegés leállt + A csevegés megállt (jelenlegi) Témák személyre szabása és megosztása. - Csevegési profil törlése? + Törli a csevegési profilt? Titkos csoport létrehozása Kapcsolódva a számítógéphez ICE-kiszolgálók beállítása @@ -289,43 +288,43 @@ készítő Megerősítés Csak nálam - %d üzenet törlése? + Töröl %d üzenetet? Egyéni témák kapcsolódás (elfogadva) Kiszolgáló címének ellenőrzése és újrapróbálkozás. - Csoport törlése? + Törli a csoportot? Adatbázis fejlesztésének megerősítése Saját profil létrehozása - cím megváltoztatása… + cím módosítása… kapcsolódás… Hívás kapcsolása - A fájlok- és a médiatartalmak törlése? + Törli a fájl- és a médiatartalmakat? befejezett CSEVEGÉSI ADATBÁZIS - Önmegsemmisító jelkód megváltoztatása - Sorbaállítás létrehozása + Önmegsemmisítő-jelkód módosítása + Sorba állítás létrehozása színezett kapcsolódás… Sötét téma törölve - Csevegési profil törlése? + Törli a csevegési profilt? Csevegés a fejlesztőkkel - Hivatkozás törlése? + Törli a hivatkozást? kapcsolódás - Egyéni időbeállítás + Egyéni időköz Kapcsolódás inkognitóban CSEVEGÉSEK Új profil létrehozása a számítógép alkalmazásban. 💻 kapcsolódás (bejelentve) kapcsolódás… Csevegési adatbázis törölve - kapcsolódás (bejelentve) + kapcsolódás (bemutatkozva) Csoporthivatkozás létrehozása Csevegési konzol Fájlok törlése az összes csevegési profilból - Sorbaállítás törlése - Ismerős törlése - cím megváltoztatása… + Sorba állítás törlése + Partner törlése + cím módosítása… Társítva a hordozható eszközhöz Jelenlegi jelmondat… Fájl kiválasztása @@ -333,33 +332,33 @@ Fájl létrehozása Tikos csoport létrehozása Elvetés - Ismerős törlése? + Törli a partnert? Kiürítés Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. - Biztonsági kódok összehasonlítása az ismerősökével. + Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás Csevegések - Üzenet törlése? - Függőben lévő ismerőskérelem törlése? + Törli az üzenetet? + Törli a függőben lévő meghívót? Adatbázis titkosítva! - Üzenetek kiürítése? + Kiüríti a csevegést? Adatbázis visszafejlesztése Üzenetek kiürítése - Adatbázis titkosítási jelmondat frissítve lesz. + Az adatbázis titkosítási jelmondata frissítve lesz. Kapcsolódás automatikusan Adatbázishiba - Adatbázis titkosítási jelmondat frissül és eltárolásra kerül a beállításokban. + Az adatbázis titkosítási jelmondata frissülni fog és a beállításokban lesz tárolva. Adatbázis-azonosító Adatbázis-azonosító: %d - Adatbázis-azonosítók és átvitel-izolációs beállítások. - Az adatbázis-titkosítási jelmondat megváltoztatásra és mentésre kerül a Keystore-ban. - Az adatbázis titkosításra kerül és a jelmondat eltárolásra a beállításokban. + Adatbázis-azonosítók és átvitelelkülönítési beállítások. + Az adatbázis titkosítási jelmondata frissülni fog és a Keystore-ban lesz tárolva. + Az adatbázis titkosítva lesz, a jelmondat pedig a beállításokban lesz tárolva. Kiszolgáló törlése Az eszközön nincs beállítva a képernyőzár. A SimpleX-zár ki van kapcsolva. Letiltás Letiltás az összes csoport számára Engedélyezés az összes csoport számára - engedélyezve az ismerős számára + engedélyezve a partner számára Az eltűnő üzenetek küldése le van tiltva. Cím törlése %d hét @@ -367,23 +366,23 @@ %dmp Kézbesítési jelentések! Az eszközön nincs beállítva a képernyőzár. A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be, miután beállította a képernyőzárat az eszközén. - Titkosítás visszafejtési hiba - Eltűnik ekkor: %s + Titkosításvisszafejtési hiba + Eltűnik: %s szerkesztve Törlés %d óra %d hónap - Cím törlése? - Kézbesítési jelentések letiltása? + Törli a címet? + Letiltja a kézbesítési jelentéseket? Az adatbázis-jelmondat eltér a Keystore-ban lévőtől. Közvetlen üzenetek E-mail - Letiltás az összes tag számára + Letiltás Fejlesztői eszközök Adatbázis-jelmondat %d nap Kapcsolat bontva - Az adatbázis egy véletlenszerű jelmondattal van titkosítva, ami megváltoztatható. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet szabadon módosíthat. %dó %dhét Felfedezés helyi hálózaton keresztül @@ -392,41 +391,41 @@ Eltűnő üzenet Ne hozzon létre címet Ne mutasd újra - SimpleX zár kikapcsolása + SimpleX-zár kikapcsolása e2e titkosított ESZKÖZ e2e titkosított videóhívás közvetlen Számítógép %d perc - %d ismerős kiválasztva + %d partner kijelölve Engedélyezés %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. %d perc - Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg a jelmondatot. - Kézbesítés jelentések letiltása a csoportok számára? + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt módosítsa. + Letiltja a kézbesítési jelentéseket a csoportok számára? nap %d nap - Duplikált megjelenített név! + Duplikált megjelenítendő név! Letiltás (felülírások megtartásával) Adatbázis fejlesztése %d üzenet letiltva - Eltűnik ekkor: + Eltűnik %d hét engedélyezve az Ön számára Eltűnő üzenetek Törlés - Törlés, és az ismerős értesítése + Törlés, és a partner értesítése letiltva - %d másodperc + %d mp Az összes fájl törlése - Az adatbázis titkosításra kerül. + Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás - Az adatbázis titkosításra kerül és a jelmondat a Keystore-ban lesz tárolva. - Automatikus üzenet törlés engedélyezése? + Az adatbázis titkosítva lesz, a jelmondat pedig a Keystore-ban lesz tárolva. + Engedélyezi az automatikus üzenettörlést? Törlés - az adatbázis verziója újabb, mint az alkalmazásé, visszafelé átköltöztetés nem lehetséges a következőhöz: %s + az adatbázis verziója újabb, mint az alkalmazásé, de a visszafelé történő átköltöztetés viszont nem lehetséges a következőhöz: %s Leírás %d óra %dp @@ -435,7 +434,7 @@ Letiltás (csoport felülírások megtartásával) %d csoportesemény %d hónap - A csoport profiljának szerkesztése + Csoportprofil szerkesztése e2e titkosított hanghívás %d mp Decentralizált @@ -448,170 +447,170 @@ Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben. alapértelmezett (%s) duplikált üzenet - Számítógép leválasztása? - Számítógép-alkalmazás verziója %s nem kompatibilis ezzel az alkalmazással. + Leválasztja a számítógépet? + A számítógép-alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. Kézbesítés - %d fájl %s összméretben + %d fájl, %s összméretben A csevegés megnyitásához adja meg az adatbázis jelmondatát. %dnap Engedélyezés az összes tag számára A kézbesítési jelentések le vannak tiltva! Kibontás - Hiba az üzenet küldésekor - Jelkód megadása + Hiba történt az üzenet elküldésekor + Adja meg a jelkódot Mindenkinél - Titkosítás újraegyeztetési hiba - Hiba az adatbázis titkosításakor - Hiba a csoport törlésekor + Hiba történt a titkosítás újraegyeztetésekor + Hiba történt az adatbázis titkosításakor + Hiba történt a csoport törlésekor Kilépés mentés nélkül A tárolt fájlok- és a médiatartalmak titkosítása - Hiba a cím beállításakor + Hiba történt a cím beállításakor A csoportmeghívó lejárt - Hiba az ICE-kiszolgálók mentésekor + Hiba történt az ICE-kiszolgálók mentésekor Hiba Hiba - Hiba az XFTP-kiszolgálók betöltésekor - Hiba az SMP-kiszolgálók betöltésekor - Hiba a hálózat konfigurációjának frissítésekor + Hiba történt az XFTP-kiszolgálók betöltésekor + Hiba történt az SMP-kiszolgálók betöltésekor + Hiba történt a hálózat konfigurációjának frissítésekor TCP életben tartása Kamera váltás Üdvözlöm!\nCsatlakozzon hozzám a SimpleX Chaten keresztül: %s - A megjelenített név nem tartalmazhat szóközöket. + A megjelenítendő név nem tartalmazhat szóközöket. Csoport - Üdvözlőüzenet megadása… (nem kötelező) - Hiba a csevegési adatbázis exportálásakor - Hiba a fájl mentésekor + Adja meg az üdvözlőüzenetet… (nem kötelező) + Hiba történt a csevegési adatbázis exportálásakor + Hiba történt a fájl mentésekor Helyi fájlok titkosítása titkosítás elfogadva %s számára %d üzenet megjelölve törlésre - titkosítás újra egyeztetése engedélyezve + a titkosítás újraegyeztetése engedélyezve van Önmegsemmisítés engedélyezése Olvasatlan és kedvenc csevegésekre való szűrés. - A csevegések betöltése sikertelen + Nem sikerült betölteni a csevegéseket A csoport már létezik! Francia kezelőfelület Csoporthivatkozások Végre, megvannak! 🚀 - Hiba a csevegés elindításakor + Hiba történt a csevegés elindításakor A csoport profilja a tagok eszközein tárolódik, nem a kiszolgálókon. - Jelmondat megadása… - Hiba a felhasználói adatvédelem frissítésekor + Adja meg a jelmondatot… + Hiba történt a felhasználói adatvédelem frissítésekor Titkosít Csoport nem található! - Hiba az SMP-kiszolgálók mentésekor + Hiba történt az SMP-kiszolgálók mentésekor Visszafejlesztés és a csevegés megnyitása A csoport inaktív Gyors és nem kell várni, amíg a feladó online lesz! - Hiba a csoporthoz való csatlakozáskor + Hiba történt a csoporthoz való csatlakozáskor Kedvenc - Csoport moderáció + Csoport moderálása Fájl Csoporthivatkozás - titkosítás-újraegyeztetés szükséges ehhez: %s - Hiba a profilváltáskor! + a titkosítás újraegyeztetése szükséges %s számára + Hiba történt a profilváltáskor! Kísérleti funkciók Engedélyezés (felülírások megtartásával) Adja meg a helyes jelmondatot. - A csoport törlésre kerül az Ön számára - ez a művelet nem vonható vissza! - Adatbázis titkosítása? + A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza! + Titkosítja az adatbázist? A zárolási képernyőn megjelenő hívások engedélyezése a Beállításokban. titkosítás elfogadva - Kézbesítési jelentések engedélyezése? - Hiba a csoportprofil mentésekor + Engedélyezi a kézbesítési jelentéseket? + Hiba történt a csoportprofil mentésekor hiba A fájl törölve lesz a kiszolgálókról. Akkor is, ha le van tiltva a beszélgetésben. Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés. Zárolás engedélyezése SÚGÓ - Teljesen decentralizált - csak a tagok számára látható. + Teljesen decentralizált – csak a tagok számára látható. Fájl: %s Hívás befejezése - Hiba a csoporthivatkozás törlésekor + Hiba történt a csoporthivatkozás törlésekor Fájl elmentve Kapcsolat javítása? Fájlok és médiatartalmak KONZOLHOZ - Sikertelen titkosítás-újraegyeztetés. - Hiba a felhasználó-profil törlésekor + Nem sikerült a titkosítást újraegyeztetni. + Hiba történt a felhasználóprofil törlésekor Csoporttag általi javítás nem támogatott - Üdvözlőüzenet megadása… + Adja meg az üdvözlőüzenetet… Titkosított adatbázis - Jelszó megadása a keresőben + Adja meg a jelszót a keresőben A fájl akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Fájl letöltése - A csevegés betöltése sikertelen - Kiszolgáló megadása kézzel + Nem sikerült betölteni a csevegést + Adja meg a kiszolgálót kézzel A fájl akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! - Hiba a csoporthivatkozás létrehozásakor + Hiba történt a csoporthivatkozás létrehozásakor A galériából Engedélyezés (csoport felülírások megtartásával) - Hiba az ismerős törlésekor + Hiba történt a partner törlésekor A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) - Hiba a szerepkör megváltoztatásakor + Hiba történt a szerepkör módosításakor Javítás A tagok küldhetnek eltűnő üzeneteket. Kapcsolat javítása - Hiba a profil létrehozásakor! - Hiba a tag(ok) hozzáadásakor + Hiba történt a profil létrehozásakor! + Hiba történt a tag(ok) hozzáadásakor Fájl A tagok küldhetnek fájlokat és médiatartalmakat. Törlés ennyi idő után - Hiba a beállítás megváltoztatásakor - Hiba a csoporthivatkozás frissítésekor + Hiba történt a beállítás módosításakor + Hiba történt a csoporthivatkozás frissítésekor a csoport törölve csoportprofil frissítve - Hiba a függőben lévő ismerős kapcsolatának törlésekor - Hiba a csevegési adatbázis importálásakor - Hiba a kézbesítési jelentések engedélyezésekor! - Hiba az XFTP-kiszolgálók mentésekor + Hiba történt a függőben lévő meghívó törlésekor + Hiba történt a csevegési adatbázis importálásakor + Hiba történt a kézbesítési jelentések engedélyezésekor! + Hiba történt az XFTP-kiszolgálók mentésekor A tagok küldhetnek egymásnak közvetlen üzeneteket. - Hiba a tag eltávolításakor + Hiba történt a tag eltávolításakor befejeződött A csoport üdvözlőüzenete - Csoport neve: - Hiba a meghívó küldésekor + Adja meg a csoport nevét: + Hiba történt a meghívó elküldésekor Adjon meg egy nevet: - Hiba a felhasználó jelszavának mentésekor + Hiba történt a felhasználó jelszavának mentésekor Téma exportálása Adja meg ennek az eszköznek a nevét… Hiba A csoportmeghívó már nem érvényes, a küldője eltávolította. A csoport teljes neve: súgó - Önmegsemmisítő jelkód engedélyezése + Önmegsemmisítő-jelkód engedélyezése KÍSÉRLETI - Hiba a cím megváltoztatásának megszakításakor - Hiba a fájl fogadásakor + Hiba történt a cím módosításának megszakításakor + Hiba történt a fájl fogadásakor titkosítás rendben - Hiba a kapcsolatkérés törlésekor - Kézbesítési jelentések engedélyezése a csoportok számára? - Ismerős általi javítás nem támogatott + Hiba történt a meghívási kérés törlésekor + Engedélyezi a kézbesítési jelentéseket a csoportok számára? + Partner általi javítás nem támogatott Fájl nem található Kapcsolat bontása A tagok reakciókat adhatnak hozzá az üzenetekhez. Adatbázis exportálása Teljes név: Tovább csökkentett akkumulátor-használat - Hiba a csevegés megállításakor + Hiba történt a csevegés megállításakor titkosítás rendben %s számára - A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az összes tag számára – ez a művelet nem vonható vissza! Titkosítás javítása az adatmentések helyreállítása után. - Hiba a csevegési adatbázis törlésekor + Hiba történt a csevegési adatbázis törlésekor Teljes hivatkozás - Hiba a cím megváltoztatásakor + Hiba történt a cím módosításakor A tagok küldhetnek hangüzeneteket. Csoportbeállítások Hiba: %s Eltűnő üzenetek SimpleX-zár bekapcsolása - Hiba a kapcsolat szinkronizálásakor - Hiba a cím létrehozásakor + Hiba történt a kapcsolat szinkronizálásakor + Hiba történt a cím létrehozásakor engedélyezve - Hiba a részletek betöltésekor - Hiba történt a kapcsolatkérés elfogadásakor - titkosítás újraegyeztetése engedélyezett %s számára - titkosítás-újraegyeztetés szükséges + Hiba történt a részletek betöltésekor + Hiba történt a meghívási kérés elfogadásakor + a titkosítás újraegyeztetése engedélyezve van %s számára + a titkosítás újraegyeztetése szükséges Rejtett csevegési profilok Fájlok és médiatartalmak A kép elmentve a „Galériába” @@ -619,11 +618,11 @@ Azonnal A fájlok- és a médiatartalmak küldése le van tiltva! Profil elrejtése - Kiszolgálók használata + Hogyan használja a saját kiszolgálóit Csevegési üzenetek gyorsabb megtalálása Téma importálása - Hiba a téma importálásakor - Ismerős nevének és az üzenet tartalmának elrejtése + Hiba történt a téma importálásakor + Partner nevének és az üzenet tartalmának elrejtése Nem kompatibilis adatbázis-verzió Hogyan működik a SimpleX Nem kompatibilis verzió @@ -653,33 +652,33 @@ Importálás Azonnali értesítések Inkognitómód - Csevegési adatbázis importálása? + Importálja a csevegési adatbázist? Az azonnali értesítések le vannak tiltva! Azonnali értesítések! Kép A fájlok- és a médiatartalmak küldése le van tiltva. Hogyan működik - Elrejtés: - Hiba az ismerőssel történő kapcsolat létrehozásában + Elrejtve: + Hiba történt a partnerrel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) - beolvashatja a QR-kódot a videohívásban, vagy az ismerőse megoszthat egy meghívó-hivatkozást.]]> - Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül! + beolvashatja a QR-kódot a videohívásban, vagy a partnere megoszthat egy meghívási hivatkozást.]]> + Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> - Megerősítés esetén az üzenetküldő-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. + Megerősítés esetén az üzenetváltó-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. A kép akkor érkezik meg, amikor a küldője befejezte annak feltöltését. QR-kód beolvasásával.]]> - A kapott SimpleX Chat-meghívó-hivatkozását megnyithatja a böngészőjében: - Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: + A kapott SimpleX Chat-meghívási hivatkozását megnyithatja a böngészőjében: + Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot: Megtalált számítógép Számítógépek A markdown használata Csevegési profil létrehozása - Levélszemét elleni védelem + Védett a kéretlen tartalommal szemben Hordozható eszközök leválasztása - Különböző nevek, profilképek és átvitel-izoláció. + Különböző nevek, profilképek és átvitelelkülönítés. Elutasítás esetén a feladó NEM kap értesítést. - Szerepkörválasztás bővítése + Szerepkörválasztó kibontása A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! meghíva Érvénytelen kapcsolattartási hivatkozás @@ -687,20 +686,20 @@ nincsenek részletek Nem fogadott hívás Világos - Az üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az üzenet törölve lesz – ez a művelet nem vonható vissza! Markdown súgó új üzenet Régi adatbázis-archívum Speciális beállítások Nincs kézbesítési információ moderált - A tag eltávolítása a csoportból - ez a művelet nem vonható vissza! - Győződjön meg arról, hogy az XFTP-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. - Nincs kiválasztva ismerős - Nincsenek fogadott, vagy küldött fájlok + A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! + Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. + Nincs partner kijelölve + Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben - meghívás a %1$s csoportba + meghívás a(z) %1$s csoportba Zárolási mód Új hordozható eszköz Kapcsolatok megtartása @@ -715,13 +714,13 @@ Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 közvetett (%1$s) Hamarosan további fejlesztések érkeznek! - Az üzenetreakciók küldése le van tiltva ebben a csevegésben. + A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben. Helytelen biztonsági kód! - Ez akkor fordulhat elő, ha Ön vagy az ismerőse régi adatbázis biztonsági mentést használt. + Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta. Új számítógép-alkalmazás! Most már az adminisztrátorok is:\n- törölhetik a tagok üzeneteit.\n- letilthatnak tagokat (megfigyelő szerepkör) meghívta őt: %1$s - A reakciók küldése az üzenetekre le van tiltva. + A reakciók hozzáadása az üzenetekhez le van tiltva. Nem nincs szöveg TAG @@ -734,14 +733,14 @@ k soha (új)]]> - Győződjön meg arról, hogy az SMP-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. + Győződjön meg arról, hogy a megadott SMP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Az onion-kiszolgálók nem lesznek használva. perc Tudjon meg többet - Új kapcsolatkérés + Új meghívási kérés Csatlakozás a csoporthoz Társított számítógép beállítások - meghíva az Ön csoporthivatkozásán keresztül + meghíva a saját csoporthivatkozásán keresztül elhagyta a csoportot Társított számítógépek Nincs alkalmazás jelkód @@ -758,7 +757,7 @@ Egy üzenet eltüntetése Végleges üzenettörlés Egyszerre csak 10 videó küldhető el - Csak Ön adhat hozzá üzenetreakciókat. + Csak Ön adhat hozzá reakciókat az üzenetekhez. elhagyta a csoportot Az üzenetek végleges törlése le van tiltva ebben a csevegésben. Max 40 másodperc, azonnal fogadható. @@ -768,34 +767,34 @@ Nincsenek háttérhívások Üzenetek Társított hordozható eszköz - Lehetővé teszi, hogy egyetlen csevegőprofilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük. + Lehetővé teszi, hogy egyetlen csevegési profilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük. Az üzenet törlésre lesz jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Elhagyás Rendben Nincsenek szűrt csevegések érvénytelen adat - Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. - Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. - A fájl betöltése - Nincs hozzáadandó ismerős + Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. + Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését. + Fájl betöltése… + Nincs hozzáadandó partner Üzenetvázlat - meghívta, hogy csatlakozzon - Egyszer használható meghívó-hivatkozás + Függőben lévő meghívó + Egyszer használható meghívó Értesítések Egyszerre csak 10 kép küldhető el - ajánlott %s: %2s + ajánlotta: %s, ekkor: %2s Nem kompatibilis! Tegye priváttá a profilját! Üzenetkézbesítési hiba - Több csevegőprofil + Több csevegési profil törlésre jelölve Némítás Hordozható eszköz társítása Értesítési szolgáltatás - Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. - A felhasználói profilok, névjegyek, csoportok és üzenetek csak az eszközön kerülnek tárolásra a kliensen belül. + Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését. + A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás - Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat. + Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat. Nincsenek előzmények Érvénytelen QR-kód Megjelölés olvasottként @@ -803,44 +802,44 @@ Megjelölés olvasatlanként Továbbiak Bejelentkezés hitelesítőadatokkal - érvénytelen üzenet formátum + érvénytelen üzenet-formátum Csatlakozás Az értesítések az alkalmazás elindításáig nem fognak működni kikapcsolva` (ez az eszköz: v%s)]]> - ajánlott %s + %s ajánlotta Csoport elhagyása - Az összes %s által írt üzenet megjelenik! - Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy az ismerőse régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült. + %s összes üzenete meg fog jelenni! + Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere egy régi adatbázis biztonsági mentését használta.\n3. A kapcsolat sérült. megfigyelő inkognitó a csoporthivatkozáson keresztül Onion-kiszolgálók használata, ha azok rendelkezésre állnak. - Ismerősök meghívása + Barátok meghívása Menük és figyelmeztetések Tagok meghívása csatlakozás mint %s - Nincs kiválasztva csevegés + Nincs csevegés kijelölve Csak helyi profiladatok - inkognitó egy egyszer használható hivatkozáson keresztül - Moderálva lett ekkor: %s - Egyszer használható meghívó-hivatkozás + inkognitó egy egyszer használható meghívón keresztül + Moderálva: %s + Egyszer használható meghívó Érvénytelen név! Beszélgessünk a SimpleX Chatben - Moderálva ekkor: + Moderálva Élő üzenetek Hitelesítés Üzenetkézbesítési jelentések! hivatkozás előnézeti képe - Csoport elhagyása? + Elhagyja a csoportot? nem Hamarosan további fejlesztések érkeznek! kikapcsolva SimpleX Chat telepítése a terminálhoz - Új megjelenített név: + Új megjelenítendő név: Új jelmondat… nem fogadott hívás Átköltöztetés: %s - Válaszul erre: + Válaszul erre Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! Információ @@ -850,7 +849,7 @@ moderálva lett %s által Győződjön meg arról, hogy a fájl helyes YAML-szintaxist tartalmaz. Exportálja a témát, hogy legyen egy példa a témafájl szerkezetére. dőlt - Érvénytelen fájl elérési útvonal + Érvénytelen a fájl elérési útvonala Csatlakozik a csoporthoz? nincs e2e titkosítás Új adatbázis-archívum @@ -860,25 +859,25 @@ Bejövő hanghívás Kulcstartóhiba Csatlakozik a csoporthoz? - Az inkognitómód védi személyes adatait azáltal, hogy az összes ismerőséhez új, véletlenszerű profilt használ. + Az inkognitómód úgy védi a személyes adatait, hogy az összes partneréhez új, véletlenszerű profilt használ. - stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más! Üzenetreakciók Nincs társított hordozható eszköz Hálózat állapota Új jelkód - Valószínűleg ez az ismerős törölte Önnel a kapcsolatot. + Valószínűleg ez a partner törölte Önnel a kapcsolatot. Csatlakozás inkognitóban Csevegés megnyitása elutasított hívás - Rendszeres + Időszakos fogadott, tiltott - Kapcsolatkérés megismétlése? - Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti őket ). (24 óra) + Megismétli a meghívási kérést? + Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) Szerepkör - SimpleX-kapcsolattartási-cím + SimpleX kapcsolattartási cím Megállítás Előre beállított kiszolgáló - Új csevegés kezdése + Új csevegés indítása Bárki üzemeltethet kiszolgálókat. Megnyitás Protokoll időtúllépése @@ -889,7 +888,7 @@ a csoporthivatkozáson keresztül Időtartam a PING-ek között Eltűnő üzenet küldése - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód Mentés és a csoportprofil frissítése Adatvédelem Profil SimpleX-címe @@ -899,50 +898,50 @@ Csak Ön tud hangüzeneteket küldeni. Frissítés Videó elküldve - Adatbázis-jelmondat megváltoztatása + Az adatbázis jelmondatának módosítása Alkalmazás beállítások megnyitása - A jelkód nem változott! + A jelkód nem módosult! Frissítés - Kiválasztás + Kijelölés Csak Ön tud hívásokat indítani. - Biztonságos sorbaállítás + Biztonságos sorba állítás Értékelje az alkalmazást - Egyszer használható meghívó-hivatkozás megosztása - Hiba az adatbázis visszaállításakor + Egyszer használható meghívó megosztása + Hiba történt az adatbázis visszaállításakor %s és %s Ön engedélyezi Csökkentett akkumulátor-használat - Mentés és az ismerősök értesítése + Mentés és a partnerek értesítése Előnézet - Csevegés használata + SimpleX Chat használata Megosztás Fogadott üzenet Üdvözlőüzenet %s, %s és további %d tag kapcsolódott - Csak az ismerőse tud hívást indítani. + Csak a partnere tud hívást indítani. TÉMÁK Túl sok videó! Üdvözöljük! - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód (beolvasás, vagy beillesztés a vágólapról) Várakozás a videóra Válasz - Ez az Ön egyszer használható meghívó-hivatkozása! + Ez a saját egyszer használható meghívója! SimpleX Chat hívások Új inkognitóprofil használata Frissítse az alkalmazást, és lépjen kapcsolatba a fejlesztőkkel. SimpleX Hivatkozás előnézete - a biztonsági kód megváltozott - Csak az ismerős nevének megjelenítése + a biztonsági kód módosult + Csak a partner nevének megjelenítése Hangszóró bekapcsolva Importált csevegési adatbázis használatához indítsa újra az alkalmazást. jogosulatlan küldés - Csak az ismerőse tud hangüzeneteket küldeni. + Csak a partnere tud hangüzeneteket küldeni. Beállítások - A kapcsolódáshoz az ismerőse beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. + A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. visszaigazolás fogadása… - Biztonsági kód beolvasása az ismerősének alkalmazásából. + Biztonsági kód beolvasása a partnere alkalmazásából. Lépjen kapcsolatba a csoport adminisztrátorával. Videó bekapcsolva Profilnév: @@ -951,25 +950,25 @@ Csillagozás a GitHubon Eltávolítás Keresés - Titkosítás újraegyeztetése? - Az önmegsemmisítési jelkód engedélyezve! + Újraegyezteti a titkosítást? + Az önmegsemmisítő-jelkód engedélyezve! Biztonsági kiértékelés Cím Üzenet elküldése Adatbázismentés visszaállítása Visszavonás - Kérje meg az ismerősét, hogy engedélyezze a hangüzenetek küldését. - Ön egy egyszer használható meghívó-hivatkozást osztott meg + Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését. + Ön egy egyszer használható meghívót osztott meg A hivatkozás megnyitása a böngészőben gyengítheti az adatvédelmet és a biztonságot. A megbízhatatlan SimpleX-hivatkozások pirossal vannak kiemelve. Saját ICE-kiszolgálók Kapcsolat létrehozása Elutasítás - Ismerős nevének és az üzenet tartalmának megjelenítése + Partner nevének és az üzenet tartalmának megjelenítése BEÁLLÍTÁSOK Profiljelszó mentése - Fájlküldés megállítása? - Számítógép leválasztása? - A hangüzenetek le vannak tilva! + Megállítja a fájlküldést? + Leválasztja a számítógépet? + A hangüzenetek le vannak tiltva! Közvetlen üzenet küldése a kapcsolódáshoz PING-ek száma Fejlesztői beállítások megjelenítése @@ -980,7 +979,7 @@ %s (jelenlegi) Saját SMP-kiszolgáló Véletlen - Megosztás az ismerősökkel + Megosztás a partnerekkel Ön Nincsenek csevegései Küldés @@ -988,23 +987,23 @@ %s: %s A SimpleX nem tud a háttérben futni. Csak akkor fog értesítéseket kapni, amikor az alkalmazás meg van nyitva. Túl sok kép! - %s, %s és %d tag + %s, %s és további %d tag Csevegési szolgáltatás megállítása SimpleX-hivatkozások - Az elküldött üzenetek törlésre kerülnek a beállított idő után. + Az elküldött üzenetek törölve lesznek a beállított idő után. Némítás megszüntetése - Elküldve ekkor: %s + Elküldve: %s Jelenlegi profil használata Ez az eszköz - Megosztja a címet az ismerőseivel? + Megosztja a címet a partnereivel? Profiljelszó Téma - Jelmondat eltávolítása a beállításokból? + Eltávolítja a jelmondatot a beállításokból? SimpleX-csoporthivatkozás Várakozás a képre Önmegsemmisítés várakozás a válaszra… - Ismerős nevének beállítása… + Partner nevének beállítása… Tag feloldása QR-kód beolvasása Kiszolgáló tesztelése @@ -1015,41 +1014,41 @@ Rendszer Elküldés Biztonsági kód - Adja meg a helyes, jelenlegi jelmondatát. + Adja meg a helyes, jelenlegi jelmondatot. Az elküldött üzenetek végleges törlése le van tiltva. - Az üzenetreakciók küldése le van tiltva. + A reakciók hozzáadása az üzenethez le van tiltva. Véletlenszerű jelmondat használata egyenrangú CSEVEGÉSI SZOLGÁLTATÁS INDÍTÁSA Kapott hivatkozás beillesztése - Kiszolgálók mentése? + Menti a kiszolgálókat? A SimpleX Chat biztonsága a Trail of Bits által lett auditálva. frissítette a csoport profilját SIMPLEX CHAT TÁMOGATÁSA SimpleX Chat szolgáltatás - Nem lehet üzeneteket küldeni! + Ön megfigyelő %s hitelesítve - Jelszó megjelenítése + Jelszó a megjelenítéshez Adatvédelem és biztonság Eltávolítás A jelkód beállítva! Elküldött üzenet - Ismerősök kiválasztása + Partnerek kijelölése ismeretlen üzenetformátum Kiszolgálók mentése Üdvözlőüzenet mp - A profilfrissítés elküldésre került az ismerősök számára. + A profilfrissítés el lesz küldve a partnerei számára. Egyszerűsített inkognitómód - Üdvözlőüzenet mentése? + Menti az üdvözlőüzenetet? Új csevegési fiók létrehozásához indítsa újra az alkalmazást. Engedély megtagadva! - Főggőben lévő hívás + Függőben lévő hívás Adatbázis megnyitása… - Leállítás? + Leállítja az alkalmazást? Jelmondat szükséges Privát értesítések - Meghívta egy ismerősét + Ön meghívta egy partnerét %s nincs hitelesítve Koppintson ide a kapcsolódáshoz Ennek az eszköznek a neve @@ -1062,7 +1061,7 @@ Adatbázis-jelmondat beállítása Üzenetbuborék színe Időszakosan indul - Ez az Ön SimpleX-címe! + Ez a saját SimpleX-címe! eltávolítva Megosztás SimpleX csapat @@ -1071,61 +1070,61 @@ tulajdonos Bekapcsolás %s, %s és %s kapcsolódott - Egyszer használható SimpleX-meghívó-hivatkozás + Egyszer használható SimpleX-meghívó Hívások nem sikerült elküldeni KEZELŐFELÜLET SZÍNEI - Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. - Másodlagos - SOCKS PROXY + Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. + Másodlagos szín + SOCKS-PROXY Mentés Újraindítás SMP-kiszolgálók Videó Automatikus elfogadási beállítások mentése - Újraegyzetetés + Újraegyeztetés Várakozás a videóra Saját XFTP-kiszolgálók Videó kikapcsolva Privát fájlnevek - Beállítások mentése? + Menti a beállításokat? Jelkód Ismeretlen hiba Saját SMP-kiszolgálójának címe - Csevegés konzol megnyitása + Csevegési konzol megnyitása Eltávolítás Adatbázis-jelmondat beállítása Biztonsági kód megtekintése - Tag feloldása? - A küldő törölhette a kapcsolatkérést. - Hibás adatbázis-jelmondat + Feloldja a tag letiltását? + A küldője törölhette a meghívási kérést. + Érvénytelen adatbázis-jelmondat Saját SMP-kiszolgálók A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása - egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívón keresztül Csoportbeállítások megadása - ezen keresztül: %1$s + a következőn keresztül: %1$s igen Hangüzenet Társítás számítógéppel PROFIL - port %d + %d-s port Kapcsolódás egy hivatkozáson keresztül Cím megosztása A kiszolgáló QR-kódjának beolvasása Megállítás - Címmegosztás megállítása? - Csevegési profilok megváltoztatása - Csatlakozáskérés megismétlése? + Megállítja a címmegosztást? + Csevegési profilok módosítása + Megismétli a meghívási kérést? Várakozás a képre Hangüzenetek - Biztosan eltávolítja? + Eltávolítja a tagot? Biztonsági kód hitelesítése eltávolította Önt SimpleX-cím - Megjelenítés: + Megjelenítve: válasz fogadása… - Adatbázismentés visszaállítása? + Visszaállítja az adatbázismentést? Üzenetek fogadása… %s és %s kapcsolódott Ön megfigyelő @@ -1133,34 +1132,34 @@ Jelkód beállítása Újdonságok Csoport megnyitása - Elküldve ekkor: + Elküldve A hangüzenetek küldése le van tiltva. - Szobák utolsó üzeneteinek megjelenítése a listanézetben + Legutóbbi üzenet előnézetének megjelenítése Az előre beállított kiszolgáló címe - Rendszeres értesítések letiltva! - A jelkód megváltozott! + Időszakos értesítések letiltva! + A jelkód módosult! Akkor fut, amikor az alkalmazás meg van nyitva Ez a QR-kód nem egy hivatkozás! Várakozás a fájlra simplexmq: v%s (%2s) Szétkapcsolás Véletlenszerű profil - Hibás jelmondat! - Az üzenetreakciók küldése le van tiltva. + Érvénytelen jelmondat! + A reakciók hozzáadása az üzenetekhez le van tiltva. Rendszer olvasatlan Függőben Üdvözöljük %1$s! - Jelmondat eltávolítása a Keystrore-ból? + Eltávolítja a jelmondatot a Keystrore-ból? Feloldás Az eltűnő üzenetek küldése le van tiltva. Videó Frissítés Megnyitás - Rendszeres értesítések + Időszakos értesítések Kihagyott üzenetek A hangüzenetek küldése le van tiltva. - Ismerős nevének beállítása + Partner nevének beállítása Csak Ön tud eltűnő üzeneteket küldeni. Médiatartalom megosztása… Ön: %1$s @@ -1168,15 +1167,15 @@ Színek visszaállítása Mentés Váltás - A kapott hivatkozás beillesztése az ismerőshöz való kapcsolódáshoz… + A kapott hivatkozás beillesztése a partnerhez való kapcsolódáshoz… Beolvasás - Port megnyitása a tűzfalon + Port nyitása a tűzfalban indítás… Leállítás elküldve - SOCKS proxy használata + SOCKS-proxy használata Élő üzenet küldése - Adatvédelem újraértelmezve + Újraértelmezett adatvédelem Hangüzenet… Alkalmazás képernyőjének védelme QR-kód megjelenítése @@ -1185,13 +1184,13 @@ Kézbesítési jelentések küldése SimpleX-cím Koppintson a - Mentés és az ismerős értesítése + Mentés és a partner értesítése Elutasított hívás - SOCKS proxy beállítások + SOCKS-proxybeállítások QR-kód Titkosítás újraegyeztetése Eltávolítás - Onion kiszolgálók használata + Onion-kiszolgálók használata Felfedés Zárolási mód Fájl visszavonása @@ -1199,41 +1198,41 @@ A fájlok- és a médiatartalmak küldése le van tiltva. Fájl megosztása… Mentés - közvetítő-kiszolgálón keresztül + egy továbbítókiszolgálón keresztül Megosztás megállítása Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása - Beállítások mentése? - Nincsenek felhasználó-azonosítók. + Menti a beállításokat? + Nincsenek felhasználói azonosítók. A közvetlen üzenetek küldése a tagok között le van tiltva. - SOCKS proxy használata? + SOCKS-proxy használata? Hangszóró kikapcsolva hét Megjelenítés WebRTC ICE-kiszolgálók - Fájl visszavonása? + Visszavonja a fájlt? Közvetlen üzenet küldése Elutasítás Küldés - Rendszerhitelesítés + Rendszer-hitelesítés Böngészőn keresztül Védje meg a csevegési profiljait egy jelszóval! - Csak az ismerőse tud eltűnő üzeneteket küldeni. + Csak a partnere tud eltűnő üzeneteket küldeni. Saját ICE-kiszolgálók - QR-kód beolvasása számítógépről + QR-kód beolvasása a számítógépről SimpleX logó Feloldás Némítás megszüntetése SimpleX Chat megnyitása a hívás fogadásához - Fájlfogadás megállítása? - - értesíti az ismerősöket a törlésről (nem kötelező)\n- profil nevek szóközökkel\n- és még sok más! + Megállítja a fájlfogadást? + - partnerek értesítése a törlésről (nem kötelező)\n- profilnevek szóközökkel\n- és még sok más! Lengyel kezelőfelület Kiszolgáló használata - Fogadva ekkor: %s + Fogadva: %s SimpleX-zár Mentés és a csoporttagok értesítése Visszaállítás - Csak az ismerőse tud üzenetreakciókat küldeni. + Csak a partnere adhat hozzá reakciókat az üzenetekhez. Hangüzenetek Ön elhagyta a csoportot Hangüzenet rögzítése @@ -1252,259 +1251,259 @@ Kapcsolat hitelesítése Tudjon meg többet A fájl küldője visszavonta az átvitelt. - Csevegési szolgáltatás megállítása? - Fogadva ekkor: + Megállítja a csevegést? + Fogadva Beállítva 1 nap Felfedés Fogadott üzenetbuborék színe - Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) - Az önmegsemmisítési jelkód megváltozott! + Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Az önmegsemmisítő-jelkód módosult! SimpleX Chat-kiszolgálók használatban. SimpleX Chat-kiszolgálók használata? Csevegési profil felfedése - Videók és fájlok 1Gb méretig - TCP kapcsolat időtúllépése - A(z) %1$s nevű profiljának SimpleX-címe megosztásra fog kerülni. + Videók és fájlok legfeljebb 1GB méretig + TCP-kapcsolat időtúllépése + A(z) %1$s nevű profilja meg lesz osztva. Ön már kapcsolódott a következőhöz: %1$s. - Jelenlegi csevegési adatbázis TÖRLÉSRE és FELCSERÉLÉSRE kerül az importált által!\nEz a művelet nem vonható vissza - profiljai, ismerősei, csevegési üzenetei és fájljai véglegesen törölve lesznek. + A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, partnerei, csevegési üzenetei és fájljai véglegesen törölve lesznek. Ötletek és javaslatok Figyelmeztetés: néhány adat elveszhet! Koppintson ide az új csevegés indításához Várakozás a számítógépre… - Az üzenetküldés jövője - Hálózati beállítások megváltoztatása? + Az üzenetváltás jövője + Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat hitelesítése fájlok küldése egyelőre még nem támogatott - cím megváltoztatva nála: %s + Ön módosította a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre - Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. + Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. videóhívás (nem e2e titkosított) Alkalmazás új kapcsolatokhoz - Az új üzenetek rendszeresen letöltésre kerülnek az alkalmazás által – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem kerülnek elküldésre a kiszolgálóknak. + Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése - kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási címhivatkozáson keresztül a SimpleX a háttérben fut a push értesítések használata helyett.]]> - Az ismerősének online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt az ismerőskérelmet és eltávolíthatja az ismerőst (ezt később ismét megpróbálhatja egy új hivatkozással). - A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonságimentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. - Az ismerősei továbbra is kapcsolódva maradnak. - A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze jelszavát + A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a partnerkérelmet és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). + A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. + A partnerei továbbra is kapcsolódva maradnak. + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Nem sikerült hitelesíteni; próbálja meg újra. Az üzenet az összes tag számára moderáltként lesz megjelölve. Értesítések fogadásához adja meg az adatbázis jelmondatát A teszt a(z) %s lépésnél sikertelen volt. Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. - Az üzenet az összes tag számára törlésre kerül. + Az üzenet az összes tag számára törölve lesz. A videó nem dekódolható. Próbálja ki egy másik videóval, vagy lépjen kapcsolatba a fejlesztőkkel. Ez a szöveg a „Beállításokban” érhető el - A profilja elküldésre kerül az ismerőse számára, akitől ezt a hivatkozást kapta. + A profilja el lesz küldve a partnere számára, akitől ezt a hivatkozást kapta. Az alkalmazás 1 perc után bezárható a háttérben. - meghívást kapott a csoportba + Ön meghívást kapott a csoportba Engedélyezze a következő párbeszédpanelen az azonnali értesítések fogadásához.]]> - A kiszolgálónak engedélyre van szüksége a sorbaállítás létrehozásához, ellenőrizze jelszavát + A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát Kapcsolódni fog a csoport összes tagjához. Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen A biztonsága érdekében kapcsolja be a SimpleX-zár funkciót.\nA funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beállítására az eszközén. A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! - Ellenőrizze a hálózati kapcsolatát a következővel: %1$s, és próbálja újra. + Ellenőrizze a hálózati kapcsolatát vele: %1$s, és próbálja újra. A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be. Az alkalmazás összeomlott - Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat. + Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat. A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel. - Érvénytelen fájl elérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. - Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %1$s). + Érvénytelen fájlelérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. + Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %1$s). A fájl fogadása le fog állni. Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani! A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését. - Ön egy egyszer használható meghívó-hivatkozást osztott meg inkognitóban - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Ön egy egyszer használható meghívót osztott meg inkognitóban + Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Később engedélyezheti a „Beállításokban” Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s %1$s.]]> Profil felfedése Ez nem egy érvényes kapcsolattartási hivatkozás! - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal. - A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől. - Ez a beállítás csak a jelenlegi csevegési profiljában lévő üzenetekre vonatkozik - Meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. + A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. + A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. + Ez a beállítás csak az Ön jelenlegi csevegési profiljában lévő üzenetekre vonatkozik + Ön meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. - Meghívást kapott a csoportba - Az ismerőse a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. - Az ismerősei és az üzenetek (kézbesítés után) nem kerülnek tárolásra a SimpleX-kiszolgálókon. + Ön meghívást kapott a csoportba + A partnere a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. + A partnerei és az üzenetek (kézbesítés után) nem a SimpleX-kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> - A csevegési profilja elküldésre kerül\naz ismerőse számára - Egy olyan ismerősét próbálja meghívni, akivel inkognitó-profilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban + A csevegési profilja el lesz küldve\na partnere számára + Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban %1$s nevű csoporthoz.]]> Amikor az alkalmazás fut - Inkognitóprofilt használ ehhez a csoporthoz - fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva - Átvitel-izoláció - Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később! + Inkognitóprofilt használ ehhez a csoporthoz – fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva + Átvitelelkülönítés + Akkor lesz kapcsolódva, ha a meghívási kérése el lesz fogadva, várjon, vagy ellenőrizze később! A hangüzenetek küldése le van tiltva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> - Biztonságos kvantumrezisztens-protokollon keresztül. - - 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése. + Biztonságos kvantumbiztos protokollon keresztül. + - legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s - Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. - Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> + Onion-kiszolgálók használata opciót „Nemre”, ha a SOCKS-proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később - A profilja az eszközén van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját. - Ön megváltoztatta %s szerepkörét erre: %s + A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. + Ön a következőre módosította %s szerepkörét: „%s” Csoportmeghívó elutasítva - Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. - (a megosztáshoz az ismerősével) + Adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. + (a megosztáshoz a partnerével) Csoportmeghívó elküldve - Átvitel-izoláció módjának frissítése? - Átvitel-izoláció + Frissíti az átvitelelkülönítési módot? + Átvitelelkülönítés Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. - A csevegési adatbázis nem titkosított - állítson be egy jelmondatot annak védelméhez. - Közvetlen internet kapcsolat használata? + A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. + Közvetlen internetkapcsolat használata? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. - A fő csevegési profilja elküldésre kerül a csoporttagok számára + A fő csevegési profilja el lesz küldve a csoporttagok számára Később engedélyezheti őket az „Adatvédelem és biztonság” menüben. - Rejtett profilja felfedéséhez írja be a teljes jelszavát a keresőmezőbe a „Csevegési profilok” menüben. + Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a „Csevegési profilok” menüben. Fejlesztés és a csevegés megnyitása - Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak. + Engedélyeznie kell a hangüzenetek küldését a partnere számára, hogy hangüzeneteket küldhessenek egymásnak. %1$s nevű csoport tagja.]]> - cím megváltoztatva - Az ismerősei engedélyezhetik a teljes üzenet törlést. - A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra. + Ön módosította a címet + A partnerei engedélyezhetik a teljes üzenet törlését. + A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. Ha engedélyezni szeretné a hordozható eszköz-alkalmazás társítását a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, miután engedélyezte azt - A profilja, az ismerősei és az elküldött üzenetei az eszközén kerülnek tárolásra. + A profilja, a partnerei és az elküldött üzenetei a saját eszközén vannak tárolva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> - Ez a karakterlánc nem egy meghívó-hivatkozás! - Új csevegés kezdése - A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül! - Nem veszíti el az ismerőseit, ha később törli a címét. + Ez a karakterlánc nem egy meghívási hivatkozás! + Új csevegés indításához + A kapcsolódás már folyamatban van ezen az egyszer használható meghívón keresztül! + Nem veszíti el a partnereit, ha később törli a címét. A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! - saját szerepköre megváltozott erre: %s + Ön a következőre módosította a saját szerepkörét: „%s” A csevegési szolgáltatás elindítható a „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával. Kód hitelesítése a hordozható eszközön - Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. + Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. a SimpleX Chat fejlesztőivel, ahol bármiről kérdezhet és értesülhet a friss hírekről.]]> Nem kötelező üdvözlőüzenettel. Ismeretlen adatbázishiba: %s - Elrejtheti vagy lenémíthatja a felhasználó-profiljait - koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. - Inkognitómód használata kapcsolódáskor. - Megoszthat egy hivatkozást vagy QR-kódot - így bárki csatlakozhat a csoporthoz. Ha a csoport később törlésre kerül, akkor nem fogja elveszíteni annak tagjait. - Csatlakozott ehhez a csoporthoz - %1$s nevű csoporthoz!]]> + Elrejtheti vagy lenémíthatja a felhasználóprofiljait – koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. + Inkognitóra váltás kapcsolódáskor. + Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoportot Ön később törli, akkor nem fogja elveszíteni annak tagjait. + Ön csatlakozott ehhez a csoporthoz + %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. Ön irányítja csevegését! Kód hitelesítése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. - A kapcsolatkérés elküldésre kerül ezen csoporttag számára. - Inkognitó-profil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott. - Már küldött egy kapcsolatkérést ezen a címen keresztül! - Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: %s. - Amikor az emberek kapcsolatot kérnek, Ön elfogadhatja vagy elutasíthatja azokat. + A meghívási kérés el lesz küldve ezen csoporttag számára. + Inkognitóprofil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott. + Már küldött egy meghívási kérést ezen a címen keresztül! + Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: %s. + Amikor az emberek meghívót küldenek, Ön elfogadhatja vagy elutasíthatja azokat. Megjelenítendő üzenet beállítása az új tagok számára! - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! - A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül. - Protokoll időtúllépése KB-onként - Az adatbázis-jelmondat megváltoztatására tett kísérlet nem fejeződött be. - Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet. - A profilja csak az ismerőseivel kerül megosztásra. + Köszönet a felhasználóknak a Weblate-en való közreműködésért! + A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. + Protokoll időtúllépése kB-onként + Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. + Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + A profilja csak a partnereivel van megosztva. Néhány kiszolgáló megbukott a teszten: Koppintson ide a csatlakozáshoz - Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerülnek. Az alacsony felbontású képek viszont megmaradnak. - A kézbesítési jelentések engedélyezve vannak %d ismerősnél - Küldés ezen keresztül: - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! - A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára. + Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. + A kézbesítési jelentések engedélyezve vannak %d partnernél + Küldés a következőn keresztül: + Köszönet a felhasználóknak a Weblate-en való közreműködésért! + A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. Bluetooth támogatás és további fejlesztések. Ez a funkció még nem támogatott. Próbálja meg a következő kiadásban. - A bejegyzés frissítve: %s + Bejegyzés frissítve: %s Tagok meghívásának kihagyása - Ezek felülbírálhatók az ismerős- és csoportbeállításokban. - Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! - A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEz később megváltoztatható. + Ezek felülbírálhatók a partner- és csoportbeállításokban. + A partnere, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! + A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEzt később módosíthatja. Koppintson ide az inkognitóban való kapcsolódáshoz Jelmondat beállítása az exportáláshoz A kézbesítési jelentések le vannak tiltva %d csoportban Néhány nem végzetes hiba történt az importáláskor: - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! - A közvetítő-kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet. - Rendszerhitelesítés helyetti beállítás. - A fogadó cím egy másik kiszolgálóra változik. A címváltoztatás a feladó online állapotba kerülése után fejeződik be. + Köszönet a felhasználóknak a Weblate-en való közreműködésért! + A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. + Beállítás a rendszer-hitelesítés helyett. + A fogadási cím egy másik kiszolgálóra fog módosulni. A cím módosítása a feladó online állapotba kerülése után fejeződik be. A csevegés megállítása a csevegési adatbázis exportálásához, importálásához vagy törléséhez. A csevegés megállításakor nem tud üzeneteket fogadni és küldeni. Jelmondat mentése a Keystore-ba - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! Jelmondat mentése a beállításokban - Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem kerülnek elküldésre. + Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem lesznek elküldve. A második jelölés, amit kihagytunk! ✅ - A közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. + A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. - A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek. + A mentett WebRTC ICE-kiszolgálók el lesznek távolítva. A kézbesítési jelentések engedélyezve vannak %d csoportban - A szerepkör meg fog változni erre: %s. A csoport tagjai értesítést fognak kapni. + A tag szerepköre a következőre fog módosulni: „%s”. A csoport összes tagja értesítést fog kapni. Profil és kiszolgálókapcsolatok - Egy üzenetküldő- és alkalmazásplatform, amely védi az adatait és biztonságát. + Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. A profil aktiválásához koppintson az ikonra. - A kézbesítési jelentések le vannak tiltva %d ismerősnél + A kézbesítési jelentések le vannak tiltva %d partnernél Munkamenet kód - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! Kis csoportok (max. 20 tag) Az Ön által elfogadott kérelem vissza lesz vonva! - Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja + Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet A KÉZBESÍTÉSI JELENTÉSEKET A KÖVETKEZŐ CÍMRE KELL KÜLDENI - A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. - Az eszköz neve megosztásra kerül a társított hordozható eszközön használt alkalmazással. + A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. + Az eszköz neve meg lesz osztva a társított hordozható eszközön használt alkalmazással. A címzettek a beírás közben látják a szövegváltozásokat. - Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni. - A jelmondat a beállításokban egyszerű szövegként kerül tárolásra, miután megváltoztatta vagy újraindította az alkalmazást. - A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói - Fogadás ezen keresztül: + Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja módosítani. + A jelmondat a beállításokban egyszerű szövegként lesz tárolva, miután módosította azt vagy újraindította az alkalmazást. + A jelenlegi csevegési profiljához tartozó új kapcsolatok kiszolgálói + Fogadás a következőn keresztül: Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez. - A szerepkör meg fog változni erre: %s. A tag új meghívót fog kapni. - profilkép helyőrző + A tag szerepköre a következőre fog módosulni: „%s”. A tag új meghívást fog kapni. + profilkép helyőrzője A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! - Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai véglegesen törlésre kerülnek. - A bejegyzés frissítve + Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek. + Bejegyzés frissítve Használati útmutatóban olvasható.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban - Az előző üzenet hasító értéke különbözik. - Ezek a beállítások csak a jelenlegi profiljára vonatkoznak + Az előző üzenet hasítóértéke különbözik. + Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub tárolónkban.]]> - hiba a tartalom megjelenítésekor - hiba az üzenet megjelenítésekor - Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”. - Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára. + Hiba történt a tartalom megjelenítésekor + Hiba történt az üzenet megjelenítésekor + Láthatóvá teheti a SimpleXbeli partnerei számára a „Beállításokban”. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás. A beillesztett szöveg nem egy SimpleX-hivatkozás. - A meghívó-hivatkozását újra megtekintheti a kapcsolat részleteinél. - Csevegés indítása? + A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél. + Elindítja a csevegést? Látható előzmények Alkalmazás jelkód - Ismerős hozzáadása + Partner hozzáadása Koppintson ide a QR-kód beolvasásához Koppintson ide a hivatkozás beillesztéséhez - Ismerős hozzáadása: új meghívó-hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.]]> + Partner hozzáadása: új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.]]> Csoport létrehozása: új csoport létrehozásához.]]> - A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt. - Az előzmények nem kerülnek elküldésre az új tagok számára. + A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. + Az előzmények nem lesznek elküldve az új tagok számára. Újrapróbálkozás A kamera nem elérhető - Az utolsó 100 üzenet elküldése az új tagok számára. - Az előzmények ne kerüljenek elküldésre az új tagok számára. + Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára. + Az előzmények ne legyenek elküldve az új tagok számára. Vagy mutassa meg ezt a kódot Kamera hozzáférés engedélyezése - Fel nem használt meghívó megtartása? - Ennek az egyszer használható meghívó-hivatkozásnak a megosztása + Megtartja a fel nem használt meghívót? + Ennek az egyszer használható meghívónak a megosztása Új csevegés Csevegések betöltése… Hivatkozás létrehozása… @@ -1516,7 +1515,7 @@ Kritikus hiba Belső hiba Nem támogatott a számítógép által használt alkalmazás verziója. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja - Számítógép-alkalmazásban hibás meghívókód szerepel + A számítógépes alkalmazásban érvénytelen meghívókód szerepel Számítógép elfoglalt Számítógép inaktív Csevegés újraindítása @@ -1524,14 +1523,14 @@ Kapcsolat bontva a számítógéppel A kapcsolat megszakadt A kapcsolat megszakadt - Számítógép kapcsolata rossz állapotban van + A kapcsolat a számítógéppel rossz állapotban van Jelentse a fejlesztőknek:\n%s\n\nAz alkalmazás újraindítása javasolt. Jelentse a fejlesztőknek: \n%s %s hordozható eszköz által használt alkalmazás verziója nem támogatott. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja]]> %s nevű hordozható eszközzel]]> Érvénytelen megjelenítendő név! - Ez a megjelenített név érvénytelen. Válasszon egy másik nevet. + Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet. %s nevű hordozható eszközzel, a következő okból: %s]]> Kapcsolat bontva a következő okból: %s %s hordozható eszköz nem található]]> @@ -1544,28 +1543,28 @@ Fejlesztői beállítások A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s %s hordozható eszköz elfoglalt]]> - Már nem tag %1$s + (Már nem tag) %1$s ismeretlen állapot - %1$s megváltoztatta a nevét erre: %2$s + %1$s a következőre módosította a nevét: %2$s eltávolította a kapcsolattartási címet eltávolította a profilképét - új kapcsolattartási cím beállítása + új kapcsolattartási címet állított be új profilképet állított be frissített profil - %1$s megváltoztatta a nevét erre: %2$s + %1$s a következőre módosította a nevét: %2$s Privát jegyzetek - Hiba a privát jegyzetek törlésekor - Hiba az üzenet létrehozásakor - Privát jegyzetek kiürítése? - Létrehozva ekkor: + Hiba történt a privát jegyzetek törlésekor + Hiba történt az üzenet létrehozásakor + Kiüríti a privát jegyzeteket? + Létrehozva Mentett üzenet - Megosztva ekkor: %s - Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza! + Létrehozva: %s + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Továbbfejlesztett üzenetkézbesítés Csatlakozás csoportos beszélgetésekhez Hivatkozás beillesztése a kapcsolódáshoz! Privát jegyzetek - A keresősáv elfogadja a meghívó-hivatkozásokat. + A keresősáv elfogadja a meghívási hivatkozásokat. Titkosított fájlokkal és médiatartalmakkal. Csökkentett akkumulátor-használattal. Magyar és török kezelőfelület @@ -1576,29 +1575,29 @@ letiltva az adminisztrátor által Letiltva az adminisztrátor által letiltotta őt: %s - Letiltás az összes tag számára - Az összes tag számára letiltja ezt a tagot? + Letiltás + Az összes tag számára letiltja a tagot? %d üzenetet letiltott az adminisztrátor - Letiltás feloldása az összes tag számára + Feloldás Az összes tag számára feloldja a tag letiltását? Ön letiltotta őt: %s - Hiba a tag az összes csoporttag számára való letiltásakor + Hiba történt a tag az összes csoporttag számára való letiltásakor Az üzenet túl nagy Az üdvözlőüzenet túl hosszú Az adatbázis átköltöztetése folyamatban van.\nEz eltarthat néhány percig. Hanghívás A hívás befejeződött Videóhívás - Hiba a böngésző megnyitásakor + Hiba történt a böngésző megnyitásakor A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további információkat a SimpleX Chat fejlesztőivel. Hálózati beállítások megerősítése - Hiba a csevegési adatbázis exportálásakor + Hiba történt a csevegési adatbázis exportálásakor Alkalmaz Archiválás és feltöltés Feltöltés megerősítése - Hiba az adatbázis törlésekor + Hiba történt az adatbázis törlésekor Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra. + Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra. Alkalmazásadatok átköltöztetése Adatbázis archiválása Átköltöztetés visszavonása @@ -1610,13 +1609,13 @@ Archívum letöltése Letöltési hivatkozás részletei Engedélyezés a közvetlen csevegésekben (BÉTA)! - Jelmondat megadása - Hiba a beállítások mentésekor - Hiba az archívum letöltésekor - Hiba az archívum feltöltésekor - Hiba a jelmondat hitelesítésekor: + Adja meg a jelmondatot + Hiba történt a beállítások mentésekor + Hiba történt az archívum letöltésekor + Hiba történt az archívum feltöltésekor + Hiba történt a jelmondat hitelesítésekor: Az exportált fájl nem létezik - A fájl törlésre került, vagy érvénytelen hivatkozás + A fájl törölve lett, vagy érvénytelen a hivatkozás %s letöltve Archívum importálása Feltöltés előkészítése @@ -1629,7 +1628,7 @@ Vagy az archívum hivatkozásának beillesztése Archívum hivatkozásának beillesztése Letöltés ismét - Sikertelen importálás + Nem sikerült az importálás Ellenőrizze, hogy a hálózati beállítások megfelelők-e ehhez az eszközhöz. A folytatáshoz a csevegést meg kell szakítani. Csevegés megállítása folyamatban @@ -1645,29 +1644,29 @@ %s feltöltve Sikertelen feltöltés Archívum feltöltése - Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, és üzenetkézbesítési hibákat okozhat + Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, mert üzenetkézbesítési hibákat okoz Importálás ismét szabványos végpontok közötti titkosítás Átköltöztetés ide Eszköz átköltöztetése Átköltöztetés egy másik eszközre - Figyelmeztetés: az archívum törlésre kerül.]]> + Figyelmeztetés: az archívum törölve lesz.]]> Átköltöztetés egy másik eszközről - Kvantumrezisztens titkosítás + Kvantumbiztos titkosítás Megpróbálhatja még egyszer. Átköltöztetés befejezve Átköltöztetés egy másik eszközre QR-kód használatával. Átköltöztetés - Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését.]]> + Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> Megpróbálhatja még egyszer. Érvénytelen hivatkozás - végpontok közötti kvantumrezisztens titkosítás + végpontok közötti kvantumbiztos titkosítás Ez a csevegés végpontok közötti titkosítással védett. Átköltöztetési párbeszédablak megnyitása - Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett. - végpontok közötti titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> - végpontok közötti kvantumrezisztens titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> - Hiba az értesítés megjelenítésekor, lépjen kapcsolatba a fejlesztőkkel. + Ez a csevegés végpontok közötti kvantumbiztos titkosítással védett. + végpontok közötti titkosítással, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve.]]> + végpontok közötti kvantumbiztos titkosítással, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve.]]> + Hiba történt az értesítés megjelenítésekor, lépjen kapcsolatba a fejlesztőkkel. Keresse meg ezt az engedélyt az Android beállításaiban, és adja meg kézzel. Engedélyezés a beállításokban Engedély(ek) megadása a hívások kezdeményezéséhez @@ -1678,7 +1677,7 @@ Engedélyek megadása Vezetékes Ethernet Mobilhálózat - Internetkapcsolat + Hálózati kapcsolat Nincs hálózati kapcsolat További Wi-Fi @@ -1694,13 +1693,13 @@ A SimpleX-hivatkozások küldése le van tiltva A fájlok- és médiatartalmak nincsenek engedélyezve A SimpleX-hivatkozások küldése engedélyezve van. - Számukra engedélyezve: + Számukra engedélyezve mentett elmentve innen: %s - Továbbítva innen: + Továbbítva innen A címzett(ek) nem látja(k), hogy kitől származik ez az üzenet. Mentett - Elmentve innen: + Elmentve innen Letöltés Továbbítás Továbbított @@ -1719,45 +1718,45 @@ Bejövő hívás csengőhangja Az üzenet forrása titokban marad. Profilképek - Profilkép alakzat + Profilkép alakzata Négyzet, kör vagy bármi a kettő között. Célkiszolgáló-hiba: %1$s - Továbbító kiszolgáló: %1$s\nHiba: %2$s - Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt. + Továbbítókiszolgáló: %1$s\nHiba: %2$s + Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. A kiszolgáló verziója nem kompatibilis a hálózati beállításokkal. - Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött. - Továbbító-kiszolgáló: %1$s\nCélkiszolgáló hiba: %2$s + Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. + Továbbítókiszolgáló: %1$s\nCélkiszolgáló-hiba: %2$s Hiba: %1$s - Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket. + Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. Üzenetkézbesítési figyelmeztetés A kiszolgáló címe nem kompatibilis a hálózati beállításokkal. Soha Ismeretlen kiszolgálók Ha az IP-cím rejtett - Üzenetállapot megjelenítése + Üzenet állapotának megjelenítése Visszafejlesztés engedélyezése Mindig Nem Nem védett Igen - Ne használjon privát útválasztást. + NE használjon privát útválasztást. Privát útválasztás Használjon privát útválasztást ismeretlen kiszolgálókkal. Mindig használjon privát útválasztást. Üzenet-útválasztási mód - Közvetlen üzenetküldés, ha az IP-cím védett és az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. - Közvetlen üzenetküldés, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Az IP-cím védelmének érdekében a privát útválasztás az SMP-kiszolgálókat használja az üzenetek kézbesítéséhez. Üzenet-útválasztási tartalék PRIVÁT ÜZENET-ÚTVÁLASZTÁS - Használjon privát útválasztást ismeretlen kiszolgálókkal, ha az IP-cím nem védett. - Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. - Tor vagy VPN nélkül az IP-címe látható lesz a fájlkiszolgálók számára. + Használjon privát útválasztást az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. + NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára. FÁJLOK IP-cím védelme - Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS proxy engedélyezve van). + Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS-proxy engedélyezve van). Ismeretlen kiszolgálók! - Tor vagy VPN nélkül az IP-címe látható lesz az XFTP-közvetítő-kiszolgálók számára:\n%1$s. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára:\n%1$s. Összes színmód Fekete Színmód @@ -1773,7 +1772,7 @@ Csevegés témája Kitöltés Profiltéma - Csevegőlista megjelenítése új ablakban + Csevegési lista megjelenítése új ablakban Világos Világos mód Fogadott válaszüzenet-buborék színe @@ -1784,12 +1783,12 @@ Válaszüzenet-buborék színe Alapértelmezett téma beállítása Rendszer - Háttérkép kiemelés + Háttérkép kiemelőszíne Háttérkép háttérszíne - További kiemelés 2 + További kiemelőszín 2 Alkalmazás téma Perzsa kezelőfelület - Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. + Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. Ismeretlen kiszolgálókról származó fájlok megerősítése. Javított üzenetkézbesítés Alkalmazás témájának visszaállítása @@ -1798,26 +1797,26 @@ Privát üzenet-útválasztás 🚀 Fájlok biztonságos fogadása Csökkentett akkumulátor-használattal. - Hiba a WebView előkészítésekor. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.\nHiba: %s + Hiba történt a WebView előkészítésekor. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.\nHiba: %s Felhasználó által létrehozott téma visszaállítása - Üzenet-sorbaállítási információ + Üzenetsorbaállítási információ nincs Kézbesítési hibák felderítése - a kiszolgáló sorbaállítási információi: %1$s\n\nutoljára kézbesített üzenet: %2$s - Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött. - Ideiglenesfájl-hiba - Üzenetállapot - Üzenetállapot: %s + a kiszolgáló sorbaállítási információi: %1$s\n\nutoljára fogadott üzenet: %2$s + Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. + Ideiglenes fájlhiba + Üzenet állapota + Üzenet állapota: %s Fájlhiba - A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták. + A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták. Fájlkiszolgáló-hiba: %1$s - Fájlállapot - Fájlállapot: %s + Fájl állapota + Fájl állapota: %s Másolási hiba Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel. Nem lehet üzenetet küldeni - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag @@ -1835,18 +1834,18 @@ Kapcsolódás Hibák Függőben - Statisztikagyűjtés kezdete: %s.\nAz összes adat biztonságban van az eszközén. + Statisztikagyűjtés kezdete: %s.\nAz összes adat privát módon van tárolva az eszközén. Elküldött üzenetek Proxyzott kiszolgálók - Újrakapcsolódás a kiszolgálókhoz? - Újrakapcsolódás a kiszolgálóhoz? - Hiba a kiszolgálóhoz való újrakapcsolódáskor + Újrakapcsolódik a kiszolgálókhoz? + Újrakapcsolódik a kiszolgálóhoz? + Hiba történt a kiszolgálóhoz való újrakapcsolódáskor Újrakapcsolódás az összes kiszolgálóhoz - Hiba a statisztikák visszaállításakor + Hiba történt a statisztikák visszaállításakor Visszaállítás Az összes statisztika visszaállítása - Az összes statisztika visszaállítása? - A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza! + Visszaállítja az összes statisztikát? + A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! Részletes statisztikák Letöltve lejárt @@ -1857,7 +1856,7 @@ Üzenetküldési hibák Közvetlenül küldött Összes elküldött üzenet - Proxyn keresztül küldve + Proxyn keresztül küldött SMP-kiszolgáló Statisztikagyűjtés kezdete: %s. Feltöltve @@ -1871,17 +1870,17 @@ Törlési hibák Méret Feltöltött fájlok - Letöltött fájltöredékek + Letöltött töredékek Letöltött fájlok Kiszolgáló-beállítások megnyitása Kiszolgáló címe Feltöltési hibák - Nyugtázva - Nyugtázott hibák + Visszaigazolt + Visszaigazolási hibák próbálkozások - Törölt fájltöredékek + Törölt töredékek Összes profil - Feltöltött fájltöredékek + Feltöltött töredékek Elkészült Kapcsolódott kiszolgálók Konfigurált XFTP-kiszolgálók @@ -1893,7 +1892,7 @@ Fogadott üzenetek Letöltési hibák Hiba - Hiba a kiszolgálókhoz való újrakapcsolódáskor + Hiba történt a kiszolgálókhoz való újrakapcsolódáskor Fájlok Betűméret Nincs információ, próbálja meg újratölteni @@ -1906,7 +1905,7 @@ Munkamenetek átvitele Összes kapcsolat Statisztikák - Információk megjelenítése ehhez: + Információk megjelenítése a következőhöz A kiszolgáló verziója nem kompatibilis az alkalmazással: %1$s. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. Aktív kapcsolatok száma @@ -1919,96 +1918,96 @@ Frissítések keresése Frissítések keresése Alkalmazásfrissítés letöltése, ne zárja be az alkalmazást - Letöltés - %s (%s) + Letöltés – %s (%s) Sikeresen telepítve Frissítés telepítése Fájl helyének megnyitása Indítsa újra az alkalmazást. Emlékeztessen később - Hagyja ki ezt a verziót + Ezen verzió kihagyása Ha értesítést szeretne kapni az új kiadásokról, kapcsolja be a stabil vagy béta verziók időszakos ellenőrzését. - Frissítés érhető el: %s + Új verzió érhető el: %s A frissítés letöltése megszakítva Béta Letiltás Letiltva Stabil - Hiba a(z) %1$s továbbító-kiszolgálóhoz való kapcsolódáskor. Próbálja meg később. - A(z) %1$s célkiszolgáló verziója nem kompatibilis a(z) %2$s továbbító kiszolgálóval. - A(z) %1$s továbbító-kiszolgáló nem tudott csatlakozni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. - A(z) %1$s célkiszolgáló címe nem kompatibilis a(z) %2$s továbbító-kiszolgáló beállításaival. + Hiba történt a(z) %1$s továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + A(z) %1$s célkiszolgáló verziója nem kompatibilis a(z) %2$s továbbítókiszolgálóval. + A(z) %1$s továbbítókiszolgáló nem tudott kapcsolódni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. + A(z) %1$s célkiszolgáló címe nem kompatibilis a(z) %2$s továbbítókiszolgáló beállításaival. Médiatartalom elhomályosítása Közepes Kikapcsolva Enyhe Erős - A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. - A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. + A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. + A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. hívás - Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza! + A partner törölve lesz – ez a művelet nem vonható vissza! Csak a beszélgetés törlése megnyitás Beszélgetés törölve! - Ismerős törölve! - Archivált ismerősök - Nincsenek szűrt ismerősök + Partner törölve! + Archivált partnerek + Nincsenek szűrt partnerek Hivatkozás beillesztése A hívások le vannak tiltva! - Nem lehet felhívni az ismerőst + Nem lehet felhívni a partnert Nem lehet üzenetet küldeni a csoporttagnak - Kapcsolódás az ismerőshöz, várjon vagy ellenőrizze később! - Törölt ismerős. + Kapcsolódás a partnerhez, várjon vagy ellenőrizze később! + Törölt partner. Nem lehet felhívni a csoporttagot - Hívások engedélyezése? + Engedélyezi a hívásokat? Meghívás üzenet Beszélgetés megtartása - Biztosan törli az ismerőst? + Biztosan törli a partnert? kapcsolódás Könnyen elérhető alkalmazás-eszköztárak Törlés értesítés nélkül Beállítások keresés videó - Az „Archivált ismerősökből” továbbra is küldhet üzeneteket neki: %1$s. - Ismerősök - Kérje meg az ismerősét, hogy engedélyezze a hívásokat. + Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %1$s. + Partnerek + Kérje meg a partnerét, hogy engedélyezze a hívásokat. Üzenet küldése a hívások engedélyezéséhez. - Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást. - A(z) %1$s nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. + Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást. + A(z) %1$s nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. Üzenet… - Kiválasztás + Kijelölés Az üzenetek az összes tag számára moderáltként lesznek megjelölve. - Nincs kiválasztva semmi + Nincs semmi kijelölve Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Törli a tagok %d üzenetét? - %d kiválasztva - Az üzenetek az összes tag számára törlésre kerülnek. + %d kijelölve + Az üzenetek az összes tag számára törölve lesznek. Csevegési adatbázis exportálva Kapcsolatok- és kiszolgálók állapotának megjelenítése. - Kapcsolódjon gyorsabban az ismerőseihez. + Kapcsolódjon gyorsabban a partnereihez. Folytatás - Ellenőrízze a hálózatát + Ellenőrizze a hálózatát Média- és fájlkiszolgálók Legfeljebb 20 üzenet egyszerre való törlése. Védi az IP-címét és a kapcsolatait. Könnyen elérhető eszköztár Üzenetkiszolgálók - SOCKS proxy - Néhány fájl nem került exportálásra: + SOCKS-proxy + Néhány fájl nem lett exportálva Az exportált adatbázist átköltöztetheti. Mentés és újrakapcsolódás Használja az alkalmazást egy kézzel. - Az ismerősök archiválása a későbbi csevegéshez. - TCP kapcsolat + A partnerek archiválása a későbbi csevegéshez. + TCP-kapcsolat Az exportált archívumot elmentheti. Tippek visszaállítása - Csevegőlista átváltása: + Csevegési lista átváltása: Ezt a „Megjelenés” menüben módosíthatja. Új médiabeállítások Lejátszás a csevegési listából. Elhomályosítás a jobb adatvédelemért. - Automatikus frissítés + Automatikus alkalmazás-frissítés Létrehozás Új verziók letöltése a GitHubról. Betűméret növelése. @@ -2017,29 +2016,29 @@ Új üzenet Érvénytelen hivatkozás Ellenőrizze, hogy a SimpleX-hivatkozás helyes-e. - Hiba a profilváltáskor + Hiba történt a profilváltáskor A kapcsolata át lett helyezve ide: %s, de egy váratlan hiba történt a profilra való átirányításkor. - Az üzenetek törlésre kerülnek - ez a művelet nem vonható vissza! - Archívum eltávolítása? - A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról. + Az üzenetek törölve lesznek – ez a művelet nem vonható vissza! + Eltávolítja az archívumot? + A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. CSEVEGÉSI ADATBÁZIS Profil megosztása Rendszerbeállítások használata - Csevegési profil kiválasztása + Csevegési profil kijelölése Ne használja a hitelesítőadatokat proxyval. Különböző proxy-hitelesítőadatok használata az összes profilhoz. Különböző proxy-hitelesítőadatok használata az összes kapcsolathoz. Jelszó Felhasználónév A hitelesítőadatai titkosítatlanul is elküldhetők. - Hiba a proxy mentésekor + Hiba történt a proxy mentésekor Győződjön meg arról, hogy a proxy konfigurációja helyes. Proxyhitelesítés Véletlenszerű hitelesítőadatok használata %1$d egyéb fájlhiba. Nincs mit továbbítani! %1$d fájl letöltése még folyamatban van. - %1$d fájlt nem sikerült letölteni. + Nem sikerült letölteni %1$d fájlt. %1$d fájl nem lett letöltve. Letöltés %1$d fájl törölve lett. @@ -2047,13 +2046,13 @@ Üzenetek továbbítása… %1$d fájlhiba:\n%2$s %1$s üzenet nem lett továbbítva - %1$s üzenet továbbítása? - Üzenetek továbbítása fájlok nélkül? - Az üzeneteket törölték miután kiválasztotta őket. + Továbbít %1$s üzenetet? + Továbbítja az üzeneteket fájlok nélkül? + Az üzeneteket törölték miután kijelölte őket. %1$s üzenet mentése - Hiba az üzenetek továbbításakor + Hiba történt az üzenetek továbbításakor Hang elnémítva - Hiba a WebView előkészítésekor. Győződjön meg arról, hogy a WebView telepítve van-e, és támogatja-e az arm64 architektúrát.\nHiba: %s + Hiba történt a WebView előkészítésekor. Győződjön meg arról, hogy a WebView telepítve van-e, és támogatja-e az arm64 architektúrát.\nHiba: %s Sarok Üzenetbuborék alakja Farok @@ -2067,19 +2066,19 @@ Továbbfejlesztett hívásélmény Továbbfejlesztett üzenetdátumok. Továbbfejlesztett felhasználói élmény - Testreszabható üzenetbuborékok. + Személyre szabható üzenetbuborékok. Legfeljebb 200 üzenet egyszerre való törlése, vagy moderálása. Legfeljebb 20 üzenet egyszerre való továbbítása. Hang/Videó váltása hívás közben. - Csevegési profilváltás az egyszer használható meghívó-hivatkozásokhoz. + Csevegési profilváltás az egyszer használható meghívókhoz. Továbbfejlesztett biztonság ✅ - A SimpleX Chat biztonsága a Trail of Bits által lett felülvizsgálva. - Hiba a kiszolgálók mentésekor + A SimpleX-protokollokat a Trail of Bits auditálta. + Hiba történt a kiszolgálók mentésekor Nincsenek üzenet-kiszolgálók. - Nincsenek üzenetfogadó-kiszolgálók. + Nincsenek üzenetfogadási kiszolgálók. Nincsenek média- és fájlkiszolgálók. A(z) %s nevű csevegési profilhoz: - Cím vagy egyszer használható meghívó-hivatkozás? + Cím vagy egyszer használható meghívó? Új kiszolgáló Címbeállítások Előre beállított kiszolgálók @@ -2087,31 +2086,31 @@ Feltételek megtekintése Nincsenek kiszolgálók a privát üzenet-útválasztáshoz. Nincsenek fájlküldő-kiszolgálók. - Nincsenek fájlfogadó-kiszolgálók. + Nincsenek fájlfogadási kiszolgálók. Hibák a kiszolgálók konfigurációjában. - Hiba a feltételek elfogadásakor + Hiba történt a feltételek elfogadásakor Kézbesítetlen üzenetek - A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van. + A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van. Nincs üzenet - Ez az üzenet törlésre került vagy még nem érkezett meg. + Ez az üzenet törölve lett vagy még nem érkezett meg. Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. - Egyszer használható meghívó-hivatkozás megosztása egy baráttal - egyetlen ismerőssel használható - személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> - Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. + Egyszer használható meghívó megosztása egy baráttal + csak egyetlen partnerrel használható – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható.]]> + Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. Kapcsolatbiztonság - A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. - A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat az ismerősével. + A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül. + A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével. A közösségi médiához Vagy a privát megosztáshoz - SimpleX-cím vagy egyszer használható meghívó-hivatkozás? - Egyszer használható meghívó-hivatkozás létrehozása - Kiszolgáló-üzemeltetők - Hálózati üzemeltetők - Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőt használ. - Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. - Válassza ki a használni kívánt hálózati üzemeltetőket. + SimpleX-cím vagy egyszer használható meghívó? + Egyszer használható meghívó létrehozása + Kiszolgálóüzemeltetők + Hálózatüzemeltetők + Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. + Például, ha a partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. + Jelölje ki a használni kívánt hálózatüzemeltetőket. Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. @@ -2120,27 +2119,27 @@ Folytatás Feltételek felülvizsgálata Elfogadott feltételek - A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltetők számára: %s. - Az Ön kiszolgálói + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára a következő időpontban: %s. + Saját kiszolgálók %s.]]> - %s.]]> + %s.]]> %s kiszolgáló - Hálózati üzemeltető + Hálózatüzemeltető Weboldal - Feltételek elfogadva ekkor: %s. - A feltételek ekkor lesznek elfogadva: %s. + Feltételek elfogadásának ideje: %s. + A feltételek el lesznek elfogadva a következő időpontban: %s. Kiszolgálók használata %s használata - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: %s.]]> - %s.]]> - %s.]]> + %s.]]> + %s.]]> %s.]]> %s.]]> %s.]]> Feltételek elfogadása Használati feltételek - %s kiszolgálók használatához fogadja el a használati feltételeket.]]> + %s kiszolgálóinak használatához fogadja el a használati feltételeket.]]> Használat az üzenetekhez A fogadáshoz A privát útválasztáshoz @@ -2149,54 +2148,54 @@ A küldéshez Hozzáadott média- és fájlkiszolgálók Feltételek megnyitása - Változások megnyitása - Hiba a kiszolgáló frissítésekor - A kiszolgáló-protokoll megváltozott. - A kiszolgáló üzemeltetője megváltozott. + Módosítások megtekintése + Hiba történt a kiszolgáló frissítésekor + A kiszolgáló-protokoll módosult. + A kiszolgáló üzemeltetője módosult. Kiszolgáló-üzemeltető Kiszolgáló hozzáadva a következő üzemeltetőhöz: %s. - Hiba a kiszolgáló hozzáadásakor + Hiba történt a kiszolgáló hozzáadásakor Átlátszóság Elhomályosítás Hálózati decentralizáció A második előre beállított üzemeltető az alkalmazásban! - Flux engedélyezése + A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. Alkalmazás-eszköztárak a metaadatok jobb védelme érdekében. Javított csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése - Az Ön jelenlegi csevegőprofiljához tartozó új fájlok kiszolgálói + A jelenlegi csevegési profiljához tartozó új fájlok kiszolgálói Vagy archívumfájl importálása Távoli hordozható eszközök Xiaomi eszközök: engedélyezze az automatikus indítást a rendszerbeállításokban, hogy az értesítések működjenek.]]> A küldéshez másolhatja és csökkentheti az üzenet méretét. - Adja hozzá csapattagjait a beszélgetésekhez. + Adja hozzá a munkatársait a beszélgetésekhez. Üzleti cím - végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumrezisztens titkosítással is rendelkeznek.]]> + végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek.]]> Hogyan segíti az adatvédelmet Nincs háttérszolgáltatás Értesítések és akkumulátor Az alkalmazás mindig fut a háttérben - Csevegés elhagyása? + Elhagyja a csevegést? Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. Csevegés törlése Meghívás a csevegésbe Barátok hozzáadása - Csapattagok hozzáadása - A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni! - A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni! - Csevegés törlése? + Munkatársak hozzáadása + A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza! + A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! + Törli a csevegést? Csevegés elhagyása - Csak a csevegés tulajdonosai módosíthatják a beállításokat. + Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat. Könnyen elérhető csevegési eszköztár - A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni! + A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! Csevegés - A szerepkör meg fog változni a következőre: %s. A csevegés tagjai értesítést fognak kapni. - Az Ön csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára + A tag szerepköre a következőre fog módosulni: „%s”. A csevegés összes tagja értesítést fog kapni. + A csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára A tagok közötti közvetlen üzenetek le vannak tiltva. Üzleti csevegések - Az Ön ügyfeleinek adatvédelme. + Saját ügyfeleinek adatvédelme. %1$s.]]> A csevegés már létezik! Csökkentse az üzenet méretét, és küldje el újra. @@ -2206,7 +2205,202 @@ A tagok közötti közvetlen üzenetek le vannak tiltva ebben a csevegésben. Amikor egynél több üzemeltető van engedélyezve, akkor egyik sem rendelkezik olyan metaadatokkal, amelyekből megtudható, hogy ki kivel kommunikál. elfogadott meghívó - kérelmezve a kapcsolódáshoz + Függőben lévő meghívási kérelem Az üzemeltetőkről A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba. - \ No newline at end of file + A titkosítás újraegyeztetése folyamatban van. + A kapcsolat titkosítása újraegyeztetést igényel. + Javítás + Kapcsolat javítása? + Naplózás engedélyezése + Hiba történt az adatbázis mentésekor + áthúzott + A következő csevegési profil törlése + Üzenetek törlése ennyi idő után + a + b + A kapcsolat nem áll készen. + Megnyitás a következővel: %s + Lista + Csoportok + Lista hozzáadása + Összes + Hozzáadás listához + Hiba történt a csevegési lista létrehozásakor + Hiba történt a csevegési lista betöltésekor + Hiba történt a csevegési lista frissítésekor + Üzleti + Partnerek + Kedvencek + Nincsenek csevegések + Nem találhatók csevegések + Nincsenek olvasatlan csevegések + Lista létrehozása + Lista mentése + Az összes csevegés el lesz távolítva a következő listáról, és a lista is törlődik: %s + Törlés + Törli a listát? + Szerkesztés + Lista neve… + Az összes lista nevének és emodzsijának különbözőnek kell lennie. + Nincsenek csevegések a(z) %s nevű listában. + Jegyzetek + Lista módosítása + Elrendezés módosítása + Hiba történt a jelentés létrehozásakor + Hiba történt a beállítások mentésekor + A jelentés archiválva lesz az Ön számára. + Tartalom jelentése: csak a csoport moderátorai látják. + Archívum + Archiválja a jelentést? + archivált jelentés + moderátor + Közösségi irányelvek megsértése + Kéretlen tartalom jelentése: csak a csoport moderátorai látják. + Csak a küldő és a moderátorok látják + Csak Ön és a moderátorok látják + Jelentés indoklása? + Kéretlen tartalom + Egyéb indoklás + Kifogásolt tartalom + Kifogásolt profil + Jelentés + Tag profiljának jelentése: csak a csoport moderátorai látják. + Egyéb jelentés: csak a csoport moderátorai látják. + Szabálysértés jelentése: csak a csoport moderátorai látják. + Jelentés archiválása + Jelentés törlése + Tagok jelentései + 1 jelentés + Jelentések + %s által archivált jelentés + %d jelentés + Kéretlen tartalom + A tartalom sérti a használati feltételeket + A kapcsolat le van tiltva + A kiszolgáló üzemeltetője letiltotta a fájlt:\n%1$s. + A kiszolgáló üzemeltetője letiltotta a kapcsolatot:\n%1$s. + Mindig kérdezzen rá + Igen + Hivatkozás megnyitása + Hivatkozás megnyitása a csevegési listából + Nem + Megnyitja a webhivatkozást? + Csevegés nevének beállítása… + Letiltja az automatikus üzenettörlést? + Üzenetek törlésének letiltása + Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. + 1 év + alapértelmezett (%s) + Csevegési üzenetek törlése a saját eszközéről. + Módosítja az automatikus üzenettörlést? + Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + A következő TCP-port használata, amikor nincs port megadva: %1$s. + TCP-port az üzenetváltáshoz + Webport használata + Olvasatlan említések + Összes némítása + Legfeljebb %1$s tagot említhet meg egy üzenetben! + Az üzenetek jelentése a moderátorok felé engedélyezve van. + Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Archiválja az összes jelentést? + Archivál %d jelentést? + Csak magamnak + Jelentések archiválása + Az összes moderátor számára + Az üzenetek jelentése ebben a csoportban le van tiltva. + A tagok jelenthetik az üzeneteket a moderátorok felé. + Az összes jelentés archiválva lesz az Ön számára. + Jelentés: %s + Tagok említése 👋 + Privát jelentések küldése + Ne maradjon le a fontos üzenetekről. + Gyorsabb üzenetküldés. + Gyorsabb csoporttörlés. + Segítsen az adminisztrátoroknak a csoportjaik moderálásában. + Privát nevek a médiafájlokhoz. + Üzenetek eltűnési idejének módosítása a csevegésekben. + Továbbfejlesztett, gyorsabb csoportok + Továbbfejlesztett adatvédelem és biztonság + Kapjon értesítést, ha megemlítik. + Csevegések listákba szervezése + elutasítva + elutasítva + A jelmondat nem olvasható a Keystore-ban, ezért kézzel szükséges megadni. Ez az alkalmazással nem kompatibilis rendszerfrissítés után történhetett meg. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. + függőben + jóváhagyásra vár + Hiba történt az adatbázis-jelmondat olvasásakor + A jelmondat nem olvasható a Keystore-ban. Ez az alkalmazással nem kompatibilis rendszerfrissítés után történhetett meg. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. + Frissített feltételek + A tagok el lesznek távolítva a csoportból – ez a művelet nem vonható vissza! + Eltávolítja a tagokat? + Az összes tag számára letiltja ezeket a tagokat? + A tagok el lesznek távolítva a csevegésből – ez a művelet nem vonható vissza! + Az összes tag számára feloldja a tagok letiltását? + Ezen tagok összes új üzenete el lesz rejtve! + A tagok összes üzenete meg fog jelenni! + moderátorok + Elfogadás + A SimpleX Chat használatával Ön elfogadja, hogy:\n- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban.\n- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek. + Adatvédelmi szabályzat és felhasználási feltételek. + A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára. + Kiszolgálóüzemeltetők beállítása + Nem támogatott kapcsolattartási hivatkozás + Rövid hivatkozások használata (béta) + Rövid hivatkozás + Teljes hivatkozás + Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől. + SimpleX-csatornahivatkozás + Összes kiszolgáló + Kikapcsolva + Előre beállított kiszolgálók + A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. + Hiba a tag befogadásakor + %d csevegés a tagokkal + %d üzenet + 1 csevegés egy taggal + %d csevegés + A jelentés el lett küldve a moderátoroknak + A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben. + függőben lévő áttekintés + áttekintés + Csevegés az adminisztrátorokkal + Csevegés a tagokkal + Tagbefogadás + Nincsenek csevegések a tagokkal + Tagok áttekintése + Tagok áttekintése a befogadás előtt (kopogtatás). + Csevegés az adminisztrátorokkal + A tag csatlakozni akar a csoporthoz, befogadja a tagot? + Eltávolítás + Befogadás + Tag befogadása + összes + Csevegés a taggal + Új tag szeretne csatlakozni a csoporthoz. + kikapcsolva + Befogadás megfigyelőként + Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérelmét. + befogadta Önt + Tagbefogadás beállítása + Elmenti a befogadási beállításokat? + Ön befogadta ezt a tagot + Befogadás tagként + befogadta őt: %1$s + áttekintve a moderátorok által + nem lehet üzeneteket küldeni + partner letiltva + csoport törölve + eltávolítva a csoportból + csatlakozási kérelem elutasítva + Ön elhagyta a csoportot + a tag régi verziót használ + Hiba a taggal való csevegés törlésekor + Ön nem tud üzeneteket küldeni! + a kapcsolat nem áll készen + nincs szinkronizálva + Törli a taggal való csevegést? + partner törölve + Csevegés törlése + Elutasítás + Elutasítja a tagot? + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg new file mode 100644 index 0000000000..39007022b8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg new file mode 100644 index 0000000000..d0d34555df --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg new file mode 100644 index 0000000000..947e78c1e5 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg new file mode 100644 index 0000000000..710213ff55 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg new file mode 100644 index 0000000000..ba3d3a393a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg new file mode 100644 index 0000000000..37c79840d7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg new file mode 100644 index 0000000000..75f247a21e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg new file mode 100644 index 0000000000..7082a9c0d4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index eeedcaa450..b69ed8405f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -216,7 +216,7 @@ TIdak pernah Mati Hanya 10 video dapat dikirim pada saat bersamaan - enkripsi ujung-ke-ujung 2 lapis.]]> + Hanya perangkat klien yang menyimpan profil pengguna, kontak, grup, dan pesan. Hanya pemilik grup yang dapat mengaktifkan pesan suara. Seluruh pesan akan dihapus - ini tidak bisa dibatalkan! Izinkan turun versi @@ -487,8 +487,8 @@ Hapus pesan tidak dapat dibatalkan dilarang dalam obrolan ini. Panggilan audio/video dilarang. Reaksi pesan dilarang. - Anggota grup dapat mengirim pesan sementara. - Pesan sementara dilarang di grup ini. + Anggota dapat mengirim pesan sementara. + Pesan sementara dilarang. %d minggu %d minggu Keamanan SimpleX Chat diaudit oleh Trail of Bits. @@ -561,11 +561,11 @@ Pesan sementara dilarang dalam obrolan ini. Pesan suara dilarang dalam obrolan ini. Anda dan kontak dapat menambahkan reaksi pesan. - Anggota grup dapat hapus pesan terkirim secara permanen. (24 jam) - Anggota grup dapat mengirim pesan suara. - Hapus pesan yang tidak dapat dibatalkan dilarang di grup ini. + Anggota dapat hapus pesan terkirim secara permanen. (24 jam) + Anggota dapat mengirim pesan suara. + Hapus pesan yang tidak dapat dibatalkan dilarang. Pesan pribadi antar anggota dilarang di grup ini. - Anggota grup dapat kirim tautan SimpleX. + Anggota dapat kirim tautan SimpleX. %d jam %d jam %d hari @@ -715,7 +715,7 @@ Kata sandi profil tersembunyi Mikrofon Privasi didefinisikan ulang - Ini dapat diubah nanti di pengaturan. + Hal yang mempengaruhi baterai Saat aplikasi sedang berjalan Notifikasi pribadi Instan @@ -960,8 +960,8 @@ Ukuran huruf Kirim pesan pribadi ke anggota dilarang. Kirim tautan SimpleX dilarang - Reaksi pesan dilarang di grup ini. - Tautan SimpleX dilarang di grup ini. + Reaksi pesan dilarang. + Tautan SimpleX dilarang. Server Tak terlindungi Sesi aplikasi @@ -997,7 +997,7 @@ Panggilan pada layar terkunci: Speaker Izin dalam pengaturan - Generasi baru\ndari perpesanan pribadi + Perpesanan masa depan Temukan izin ini di pengaturan Android dan ubah secara manual. Earpiece Matikan @@ -1052,9 +1052,9 @@ Kirim hingga 100 pesan terakhir untuk anggota baru. Kirim pesan sementara dilarang. Jangan perlihat pesan riwayat ke anggota baru. - Anggota grup dapat mengirim pesan pribadi. - Pesan suara dilarang di grup ini. - Anggota grup dapat memberi reaksi pesan. + Anggota dapat mengirim pesan pribadi. + Pesan suara dilarang. + Anggota dapat memberi reaksi pesan. %d bulan pemilik %d dtk @@ -1064,8 +1064,8 @@ %db %dbln Apa yang baru - Anggota grup dapat kirim berkas dan media. - Berkas dan media dilarang di grup ini. + Anggota dapat kirim berkas dan media. + Berkas dan media dilarang. Riwayat pesan tidak dikirim ke anggota baru. Sembunyikan layar aplikasi di aplikasi terbaru. Kontak Anda dapat mengizinkan hapus semua pesan. @@ -1160,7 +1160,7 @@ Gagal perbarui privasi pengguna Fungsi lambat Notifikasi instan - izinkan SimpleX berjalan di latar belakang pada dialog berikutnya. Jika tidak, notifikasi akan dimatikan.]]> + Izinkan pada dialog berikutnya untuk menerima pemberitahuan secara instan.]]> Optimalisasi baterai aktif, mematikan layanan latar belakang dan permintaan pesan baru secara berkala. Anda dapat aktifkan kembali di pengaturan. Buka pengaturan aplikasi Notifikasi berkala dinonaktifkan! @@ -1265,7 +1265,7 @@ Server perlu otorisasi untuk mengunggah, periksa kata sandi Buat antrian Dapat dimatikan melalui pengaturan – notifikasi akan tetap ditampilkan saat aplikasi berjalan.]]> - layanan latar belakang SimpleX – yang gunakan beberapa persen baterai per hari.]]> + SimpleX berjalan di latar belakang alih-alih gunakan notifikasi push.]]> Aplikasi terima pesan baru secara berkala — aplikasi ini memakai beberapa persen baterai per hari. Aplikasi ini tidak gunakan notifikasi push — data dari perangkat tidak dikirim ke server. Layanan SimpleX Chat Nama kontak @@ -1347,4 +1347,1016 @@ langsung Kirim via Terima via - \ No newline at end of file + Kontak diperiksa + Tak dapat undang kontak! + enkripsi disetujui untuk %s + pembuat + menghubungkan + Hapus grup? + Grup akan dihapus untuk semua anggota - ini tidak dapat dibatalkan! + Buat tautan grup + menghubungkan (diperkenalkan) + kode keamanan berubah + %d kontak dipilih + Tautan grup + Anggota lama %1$s + enkripsi end-to-end standar + enkripsi e2e quantum resistant + menghubungkan (undangan perkenalan) + tidak dikenal + Hapus obrolan + Tambah teman + Tambah anggota tim + Keluar grup + menghubungkan (diumumkan) + menghubungkan (diterima) + Hapus grup + Grup akan dihapus untuk Anda - ini tidak dapat dibatalkan! + Hapus obrolan? + Obrolan akan dihapus untuk semua anggota - ini tidak dapat dibatalkan! + Obrolan akan dihapus untuk Anda - ini tidak dapat dibatalkan! + Tinggalkan obrolan + Edit profil grup + Pesan sambutan + Undang anggota + anda: %1$s + Anda mencoba mengundang kontak yang telah Anda bagikan profil samaran ke grup tempat Anda menggunakan profil utama + Undang ke grup + Undang ke obrolan + Pilih kontak + Lewati undang anggota + Alamat atau tautan 1-kali? + Opsi pengembang + Operator server + Operator jaringan + Aplikasi ini melindungi privasi Anda dengan gunakan operator yang berbeda setiap percakapan. + Ketentuan akan diterima untuk operator yang diaktifkan setelah 30 hari. + Alat pengembang + Warna obrolan + terhubung langsung + Koneksi ke desktop dalam kondisi buruk + Konfirmasi hapus kontak? + Waktu kustom + Pilih berkas + terhubung ke pengembang SimpleX Chat untuk mengajukan pertanyaan dan menerima pembaruan.]]> + Hapus obrolan? + Jangan gunakan kredensial dengan proxy. + Gunakan host .onion ke Tidak jika proxy SOCKS tidak mendukung.]]> + Harap diperhatikan: relay pesan dan berkas terhubung melalui proxy SOCKS. Panggilan dan pengiriman pratinjau tautan menggunakan koneksi langsung.]]> + JANGAN mengirim pesan secara langsung, meskipun server Anda atau server tujuan tidak mendukung routing pribadi. + Nama tampilan tidak boleh terdapat spasi. + Frasa sandi acak disimpan dalam pengaturan sebagai teks biasa.\nAnda dapat mengubahnya nanti. + Pasang frasa sandi basis data + Basis data akan dienkripsi. + Peningkatan basis data + Konfirmasi peningkatan basis data + Turunkan dan buka obrolan + mengubah hak %s menjadi %s + Dihapus pada: %s + Hapus profil + %1$s!]]> + Batal pindah + Potongan diunggah + hubungkan + Percakapan dihapus! + Pesan sementara + Hapus catatan pribadi? + Obrolan dihentikan + Frasa sandi basis data + Konfirmasi frasa sandi baru… + Ubah frasa sandi basis data? + Tidak dapat mengakses Keystore untuk menyimpan kata sandi basis data + Penurunan basis data + Obrolan dihentikan + Buat tautan + Dihapus di + Pesan pribadi antar anggota dilarang dalam obrolan ini. + Tanda terima pengirim! + (perangkat ini v%s)]]> + Perangkat + Koneksi terputus + Gagal salin + Temukan melalui jaringan lokal + Kode undangan desktop salah + Desktop terputus + %1$s.]]> + Konfirmasi pengaturan jaringan + Potongan dihapus + Potongan diunduh + Berkas terunduh + Gagal unduh + Hapus semua berkas + Hapus berkas dan media? + %d berkas dengan total ukuran %s + Lanjutkan + kontak dihapus + Tak dapat undang kontak! + Terhapus pada + Terhapus pada: %s + (saat ini) + %s.]]> + Buram + Transparansi + Bilah alat aplikasi + Status koneksi dan server. + Desktop memiliki versi tidak didukung. Pastikan Anda gunakan versi yang sama di kedua perangkat + Obrolan dipindahkan! + Hapus basis data dari perangkat ini + Hapus berkas untuk semua profil obrolan + Dibuat di + Terbaik untuk baterai. Anda akan menerima notifikasi saat aplikasi sedang berjalan (TANPA layanan latar belakang).]]> + Baik untuk baterai. Aplikasi memeriksa pesan setiap 10 menit. Anda mungkin melewatkan panggilan atau pesan penting.]]> + Tema obrolan + BASIS DATA OBROLAN + Basis data dienkripsi menggunakan frasa sandi acak. Harap ubah frasa sandi sebelum mengekspor. + Basis data obrolan diekspor + Frasa sandi saat ini… + Harap diperhatikan: Anda TIDAK akan dapat pulihkan atau ubah frasa sandi jika hilang.]]> + Basis data dienkripsi menggunakan frasa sandi acak, Anda dapat mengubahnya. + Basis data akan dienkripsi dan frasa sandi disimpan dalam pengaturan. + versi database lebih baru daripada aplikasi, tetapi tidak ada penurunan migrasi untuk: %s + %d acara grup + mengubah alamat untuk Anda + ID basis data + Menghubungkan ke kontak, harap tunggu atau periksa nanti! + Hapus profil obrolan? + Gagal hapus + Perangkat Xiaomi: harap aktifkan Autostart di pengaturan sistem agar notifikasi berfungsi.]]> + Hapus obrolan + Hubungkan via tautan / kode QR + Profil acak baru akan dibagikan. + Permintaan koneksi terkirim! + Keamanan koneksi + Nama telah benar untuk %s? + Anda dapat konfigurasi operator di pengaturan Jaringan dan server. + Kesalahan basis data + Frasa sandi basis data berbeda dengan yang disimpan di Keystore. + Ubah hak grup? + %s.]]> + Ketentuan Penggunaan + %s.]]> + %s, terima ketentuan penggunaan.]]> + Menambahkan server pesan + Hapus profil obrolan + Obrolan bisnis + Terputus dikarenakan: %s + %s dengan alasan: %s]]> + Desktop sedang sibuk + grup dihapus + kontak %1$s diubah menjadi %2$s + Panggilan dilarang! + %s.]]> + %s.]]> + Teks ketentuan saat ini tidak dapat dimuat, Anda dapat meninjau ketentuan via tautan ini: + Pesan pribadi antar anggota dilarang. + Pindah dari perangkat lain pada perangkat baru dan pindai kode QR.]]> + Kontak dan semua pesan akan dihapus - ini tidak dapat dibatalkan! + Kontak akan dihapus - ini tidak dapat dibatalkan! + Hapus kontak? + Kontak dihapus! + Hapus tanpa notifikasi + Ubah alamat penerima? + Terputus + Tambahkan kontak: untuk buat tautan undangan baru, atau terhubung via tautan yang Anda terima.]]> + Hapus koneksi yang tertunda? + Kontak belum terhubung! + Hapus alamat + Hapus basis data + Basis data obrolan dihapus + Basis data obrolan diimpor + Dimatikan + Kontrol jaringan Anda + Menghubungkan ke desktop + Versi aplikasi desktop %s tidak kompatibel dengan aplikasi ini. + %s dalam kondisi buruk]]> + Autentikasi perangkat tidak diaktifkan. Anda dapat aktifkan SimpleX Lock via Pengaturan, setelah mengaktifkan autentikasi perangkat. + Autentikasi tidak tersedia + Konfirmasi kredensial Anda + Autentikasi perangkat mati. Matikan SimpleX Lock. + Matikan Kunci SimpleX + Kesalahan server tujuan: %1$s + Hapus dan beritahu kontak + Hapus kontak + Batal pesan langsung + Konfirmasi + Buat grup rahasia + Kamera tidak tersedia + Pindai kode QR.]]> + Buka di aplikasi seluler, lalu ketuk Hubungkan di aplikasi.]]> + Hapus + Hapus + batal pratinjau tautan + Tombol tutup + Email + pindai kode QR dalam panggilan video, atau kontak Anda dapat bagikan tautan undangan.]]> + Panduan Pengguna.]]> + Aktifkan + Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. + Obrolan sedang berjalan + Basis data akan dienkripsi dan frasa sandi disimpan di Keystore. + mengubah hak Anda jadi %s + mengubah alamat… + mengubah alamat untuk %s… + ID basis data: %d + Menambahkan media dan berkas server + Obrolan sudah ada! + Basis data dienkripsi! + Frasa sandi enkripsi basis data akan diperbarui dan disimpan di pengaturan. + Frasa sandi enkripsi basis data akan diperbarui dan disimpan di Keystore. + migrasi berbeda di aplikasi/basis data: %s / %s + tunjukkan kode QR dalam panggilan video, atau bagikan tautan.]]> + Jangan buat alamat + Rincian + Koneksi + Terhubung ke ponsel + Jangan aktifkan + Gunakan dari desktop di aplikasi seluler dan pindai kode QR.]]> + Desktop + undangan diterima + diminta untuk terhubung + Ubah profil obrolan + Kapasitas terlampaui - penerima tidak menerima pesan yang dikirim sebelumnya. + hanya dengan satu kontak - bagikan secara langsung atau melalui messenger apa pun.]]> + Disalin ke papan klip + Buat tautan undangan satu-kali + Saat ini maksimal ukuran berkas adalah %1$s. + Buka di ponsel.]]> + Pengaturan alamat + Tambahkan anggota tim Anda ke percakapan. + Alamat bisnis + dengan enkripsi end-to-end, dengan keamanan post-quantum dalam pesan pribadi.]]> + Buat tautan 1-kali + Notifikasi dan baterai + Tiada layanan latar belakang + Aplikasi selalu berjalan di latar belakang + Periksa pesan setiap 10 menit + Pilih operator jaringan yang akan digunakan. + Misalnya, jika kontak Anda menerima pesan melalui server SimpleX Chat, aplikasi Anda akan mengirimkannya melalui server Flux. + Bagaimana ini membantu privasi + Lanjutkan + Anda dapat konfigurasi server di pengaturan. + repositori GitHub kami.]]> + Nilai nanti + Perbarui + Rincian tautan unduhan + untuk setiap profil obrolan yang Anda miliki di aplikasi.]]> + untuk setiap kontak dan anggota grup.\nHarap diperhatikan: jika Anda memiliki banyak koneksi, konsumsi baterai dan lalu lintas dapat jauh lebih tinggi dan beberapa koneksi mungkin gagal.]]> + Mengunduh pembaruan aplikasi, jangan tutup aplikasi + Mati + Buat alamat SimpleX + Buat alamat agar orang dapat terhubung dengan Anda. + Lanjutkan + Hapus gambar + Edit gambar + Mengonsumsi banyak baterai! Aplikasi selalu berjalan di latar belakang – notifikasi ditampilkan secara instan.]]> + Sudut + Jangan tampilkan lagi + Konfirmasi unggahan + Arsip dan unggah + Dihapus + Koneksi dihentikan + Alamat desktop + Perangkat desktop + Tentang operator + SimpleX Chat dan Flux membuat kesepakatan untuk sertakan server yang dioperasikan Flux ke aplikasi. + Hapus profil obrolan? + Obrolan dihentikan. Jika Anda sudah gunakan basis data ini di perangkat lain, Anda harus transfer kembali sebelum memulai obrolan. + Frasa sandi enkripsi basis data akan diperbarui. + Frasa sandi basis data diperlukan untuk membuka obrolan. + %s.]]> + tidak boleh menggunakan basis data yang sama pada dua perangkat.]]> + Terhubung ke desktop + Hubungkan ke desktop + hari + kesalahan dekripsi + Putuskan ponsel + Dapat ditemukan melalui jaringan lokal + duplikat + Keamanan yang lebih baik ✅ + Bentuk pesan yang dapat disesuaikan. + Harap diperhatikan: menggunakan database yang sama pada dua perangkat akan merusak dekripsi pesan dari koneksi Anda, sebagai perlindungan keamanan.]]> + %1$s.]]> + mengubah alamat… + Hapus tautan? + Hapus tautan + Putuskan + Server terhubung + Hapus pesan anggota? + Tanggal pesan lebih baik. + (baru)]]> + %1$s.]]> + Dibuat + Hapus %d pesan anggota? + Gagal simpan server + Galat: %1$s + Gambar akan diterima setelah kontak Anda selesai mengunggah. + Gagal simpan berkas + Tautan tidak valid! + Instal pembaruan + Sembunyikan: + Untuk media sosial + Fitur eksperimental + Aktifkan Kunci SimpleX + Berkas tidak ditemukan - kemungkinan besar telah dihapus atau dibatalkan. + Kesalahan berkas server: %1$s + Balasan untuk + Berkas + gambar + Favorit + Keluar tanpa menyimpan + Berkas: %s + undangan ke grup %1$s + Grup tidak ditemukan! + Gagal kirim undangan + Undang + Status berkas + Gagal ganti hak + Gagal hapus anggota + Samaran + Mode samaran melindungi privasi Anda dengan menggunakan profil acak baru untuk setiap kontak. + Gagal aktifkan tanda terima pengirim! + Masukkan nama perangkat ini… + Impor gagal + Gagal unduh arsipan + Berkas yang diekspor tidak ada + Selesaikan migrasi + Gagal atur ulang statistik + Gagal impor basis data obrolan + Gagal hapus basis data obrolan + Berkas dan media + Enkripsi basis data? + Versi basis data tidak kompatibel + UNTUK KONSOL + Grup sudah ada! + Masukkan frasa sandi + Aktifkan hapus pesan otomatis? + Basis data terenkripsi + Konfirmasi migrasi tidak valid + Undangan grup kedaluwarsa + negosiasi ulang enkripsi diperbolehkan + negosiasi ulang enkripsi diperlukan + negosiasi ulang enkripsi diizinkan untuk %s + Aktifkan Flux di pengaturan Jaringan dan server untuk privasi metadata yang lebih baik. + untuk privasi metadata lebih baik. + Navigasi obrolan ditingkatkan + Galat + Selesaikan migrasi pada perangkat lain. + Gagal verifikasi frasa sandi: + Gagal hubungkan ulang server + Gagal hubungkan ulang server + EKSPERIMENTAL + Ekspor basis data + Impor basis data + Gagal hentikan obrolan + Gagal ganti pengaturan + Galat: %s + Grup tidak aktif + Undangan kedaluwarsa! + profil grup diperbarui + Hak awal + Gagal membuat tautan grup + Gagal hapus tautan grup + Gagal perbarui tautan grup + Gagal membuat kontak anggota + Sembunyikan + Gambar akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Kesalahan berkas + Dari Galeri + Cara menggunakan markdown + Undangan grup tidak lagi berlaku, telah dihapus oleh pengirim. + Untuk routing pribadi + Gagal perbarui server + Masukkan kata sandi dalam pencarian + Masukkan frasa sandi yang benar. + jam + Versi tidak kompatibel + Galat + Server penerusan: %1$s\nKesalahan: %2$s + Server penerusan: %1$s\nKesalahan server tujuan: %2$s + Berkas dan media dilarang! + Berkas akan diterima setelah kontak Anda selesai mengunggah. + Berkas akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Aktifkan akses kamera + Jika Anda menerima tautan undangan SimpleX Chat, Anda dapat buka di peramban Anda: + bantuan + Jika Anda tidak dapat bertemu langsung, tunjukkan kode QR dalam panggilan video, atau bagikan tautan. + Aktifkan TCP keep-alive + Gagal memulai obrolan + Mode samaran + Gagal hapus basis data + Kode QR tidak valid + Gagal enkripsi basis data + Galat + Impor + kedaluwarsa + Jika Anda memilih menolak, pengirim TIDAK akan diberitahu. + Kesalahan dalam konfigurasi server. + Untuk profil obrolan %s: + Gagal menerima ketentuan + Berhasil diinstal + Sembunyikan profil + Masukkan frasa sandi… + Berkas telah dihapus atau tautan tidak valid + Masukkan pesan sambutan… (opsional) + Nama lengkap: + Untuk melanjutkan, obrolan harus dihentikan. + negosiasi ulang enkripsi diperlukan untuk %s + Perluas pemilihan hak + Ditemukan desktop + Gagal simpan pengaturan + enkripsi ok untuk %s + Gagal ekspor basis data obrolan + Impor basis data obrolan? + tidak langsung (%1$s) + Gagal menambah server + Gagal perbarui arsipan + Gagal blokir anggota untuk semua + Gagal simpan kata sandi pengguna + Tautan tidak valid + Tiada server pesan. + Tiada server untuk routing pesan pribadi. + Tiada server media dan berkas. + Tiada server untuk menerima pesan. + Atau bagikan secara pribadi + Simpan dan beritahu anggota grup + %s (saat ini) + Pesan dari %s akan ditampilkan! + Masuk dengan kredensial Anda + Kirim + Simpan dan beritahu kontak + ID pesan salah + Kesalahan Keychain + Simpan frasa sandi dan buka obrolan + Buka obrolan + Frasa sandi tidak ditemukan di Keystore, silakan masukkan secara manual. Hal ini mungkin terjadi jika Anda memulihkan data aplikasi menggunakan alat cadangan. Jika tidak demikian, silakan hubungi pengembang. + Toolbar aplikasi yang dijangkau + Anggota akan dihapus dari grup - ini tidak dapat dibatalkan! + Batas waktu protokol per KB + Menerima konkurensi + Kata sandi profil + Antarmuka Italia + Hubungkan ponsel + Anda dapat aktifkan nanti di pengaturan Privasi dan Keamanan aplikasi. + Opsi desktop tertaut + Anda dapat mencoba lagi. + Reset + Mulai ulang + Mulai ulang aplikasi untuk gunakan basis data obrolan yang diimpor. + Pulihkan cadangan basis data + Gagal pulihkan basis data + Anda dapat bagikan tautan atau kode QR - siapa pun dapat bergabung ke grup. Anda tidak akan kehilangan anggota grup jika nanti Anda hapus. + Tanda terima dimatikan + Anda dapat bagikan alamat ini dengan kontak agar dapat terhubung dengan %s. + Anda dapat aktifkan nanti di Pengaturan + Kirim tanda terima pengirim akan diaktifkan untuk semua kontak di semua profil obrolan yang terlihat. + Atau tempel tautan arsip + Mempersiapkan unduhan + Pindah ke sini + Impor ulang + Harap konfirmasi bahwa pengaturan jaringan sudah benar untuk perangkat ini. + Mempersiapkan unggahan + Hubungkan ulang semua server yang terhubung untuk paksa kirim pesan. Ini menggunakan lalu lintas tambahan. + Hubungkan ulang server? + Reset semua statistik + Reset seluruh statistik? + Hubungkan ulang semua server + Simpan frasa sandi di Keystore + Nama lokal + Dikirim pada + Diterima pada + Reset ke bawaan + Jaringan desentralisasi + Kirim tanda terima pengirim akan diaktifkan untuk semua kontak. + Desktop terhubung + %s diunduh + Pesan diterima + Catatan diperbarui pada + PESAN DAN BERKAS + Tema profil + Gambar profil + Harap masukkan frasa sandi saat ini yang benar. + Migrasi: %s + %s terhubung + anggota %1$s berubah menjadi %2$s + Status pesan + Dimoderasi pada + Kirim pesan pribadi + Batas waktu protokol + Simpan dan hubungkan ulang + Memungkinkan adanya banyak koneksi anonim tanpa ada data bersama di antara mereka dalam satu profil obrolan. + Hubungkan ulang server? + Pesan terlalu besar! + Mohon kurangi ukuran pesan atau hapus media dan kirim lagi. + Mohon kurangi ukuran pesan dan kirim lagi. + Anda dapat salin dan kurangi ukuran pesan untuk kirim. + Reset + Kirim pesan langsung - pesan akan diperbarui pada penerima saat Anda mengetik + Kirim Pesan + Tandai belum dibaca + Anda dapat setel nama koneksi, untuk mengingat dengan siapa tautan dibagikan. + Buka lokasi berkas + Silakan mulai ulang aplikasi. + Gabung ke samaran + %s pada %s + Anggota akan dihapus dari obrolan - ini tidak dapat dibatalkan! + Server operator + Server ditambah ke operator %s. + Operator server berubah. + Protokol server berubah. + Jumlah PING + Interval PING + - Buka obrolan pada pesan belum dibaca pertama.\n- Lompat ke pesan yang dikutip. + Pilih + Hubungkan ulang server untuk paksa kirim pesan. Ini menggunakan lalu lintas tambahan. + Bilah alat obrolan yang dijangkau + foto profil dihapus + %s dan %s terhubung + Privasi untuk pelanggan Anda. + Unduh ulang + Anda dapat mencoba lagi. + Hapus arsip? + Unggah ulang + Simpan percakapan + Anda dapat kirim pesan ke %1$s dari kontak yang diarsip. + Alamat penerima akan diubah ke server lain. Perubahan alamat akan selesai setelah pengirim online. + Minta kontak Anda untuk aktifkan kirim pesan suara. + gambar profil + Simpan pengaturan terima otomatis + Catatan diperbarui pada: %s + dtk + Alamat desktop salah + Pindai kode QR dari desktop + Pesan terkirim + dimoderasi oleh %s + Buka konsol obrolan + Peringatan kirim pesan + Kesalahan kirim pesan + Penerima tidak dapat melihat siapa pengirim pesan ini. + Silakan hubungi admin grup. + Memuat berkas + Harap tunggu sementara berkas sedang dimuat dari ponsel yang terhubung + Tertunda + Negosiasi ulang enkripsi? + Negosiasi ulang + Kirim pesan sementara + Kirim + Pindai kode QR + (pindai atau tempel dari papan klip) + Izin Ditolak! + Tandai dibaca + tautan pratinjau gambar + Anda dapat bagikan alamat sebagai tautan atau kode QR - siapa pun dapat terhubung dengan Anda. + Simpan + Jadikan profil pribadi! + Ponsel jarak jauh + JALANKAN OBROLAN + Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengakses obrolan jika hilang. + dihapus %1$s + Dikirim pada: %s + ID pesan salah + Hapus frasa sandi dari Keystore? + Hapus frasa sandi dari pengaturan? + Simpan frasa sandi di pengaturan + Diproxy + Tempel tautan yang Anda terima untuk terhubung dengan kontak… + lainnya + Kirim pesan langsung + Pesan langsung! + Kode QR + Memindah + Alamat server + Buka pengaturan server + alamat kontak dihapus + Putar dari daftar obrolan. + Ponsel terhubung + Pindai dari ponsel + Lainnya + Tiada server untuk menerima berkas. + Tiada server untuk kirim berkas. + Tiada pesan + Buka layar migrasi + Pesan akan dihapus - ini tidak dapat dibatalkan! + tempat penampung gambar profil + Alamat server tidak kompatibel dengan pengaturan jaringan. + Kata sandi ditampilkan + Ingatkan nanti + Harap masukkan kata sandi sebelumnya setelah memulihkan cadangan basis data. Tindakan ini tidak dapat dibatalkan. + Pulihkan + Anda dapat mulai obrolan via Pengaturan aplikasi / Basis Data atau dengan mulai ulang aplikasi. + Hanya pemilik obrolan yang dapat ubah preferensi. + Kirim tanda terima + detik + Harap laporkan ke pengembang:\n%s\n\nDisarankan untuk memulai ulang aplikasi. + menit + Kredensial SOCKS baru akan digunakan setiap kali Anda memulai aplikasi. + Mari bicara di SimpleX Chat + Simpan dan beritahu kontak + Simpan preferensi? + Anda dapat buat nanti + Gabung ke grup? + Bergabung dengan grup + Minta kontak Anda untuk aktifkan panggilan. + Anda dapat sembunyikan atau matikan profil pengguna - tekan tahan untuk buka menu. + Penggunaan baterai sedikit + Berhasil dipindah + Rekam pesan suara + Penerima pesan + Hash pesan salah + Keluar dari obrolan? + Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengubahnya jika hilang. + Pulihkan cadangan basis data? + Dimoderasi pada: %s + Diterima pada: %s + Atau impor berkas arsip + Harap laporkan ke pengembang:\n%s + Nama profil: + Tertunda + Server yang terhubung sebelumnya + Server proxy + Pesan akan ditandai sebagai dihapus. Penerima tetap dapat melihat pesan tersebut. + Pesan akan ditandai sebagai dihapus. Penerima tetap dapat melihat pesan ini. + Perbaiki + Perbaiki koneksi? + Server baru + Info antrian pesan + Bentuk pesan + ROUTING PESAN PRIBADI + info antrean server: %1$s\n\npesan terakhir diterima: %2$s + Hanya data profil lokal + Buka perubahan + Ketentuan terbuka + Atau bagikan tautan berkas dengan aman + Tempel alamat desktop + Profil dan koneksi server + Kesalahan lainnya + Aman + Kesalahan kirim + Dikirim langsung + Dikirim via proxy + Pembaruan profil akan dikirim ke kontak Anda. + Tempel tautan arsip + Harap periksa apakah perangkat seluler dan desktop terhubung ke jaringan lokal yang sama, dan firewall desktop mengizinkan koneksi.\nHarap sampaikan masalah lain kepada pengembang. + Koneksi memerlukan negosiasi ulang enkripsi. + Negosiasi ulang enkripsi sedang berlangsung. + Pesan yang tidak terkirim + Pesan ini telah dihapus atau belum diterima. + Untuk terhubung via tautan + Untuk mendapatkan pemberitahuan tentang rilis baru, aktifkan pemeriksaan berkala untuk versi Stabil atau Beta. + Alamat SimpleX atau tautan 1-kali? + Matikan + Lihat konsol di jendela baru + Bunyikan + Putuskan + Buka + Kunci salah atau alamat potongan berkas tidak dikenal - kemungkinan berkas dihapus. + Versi server tidak kompatibel dengan pengaturan jaringan. + Anda adalah pengamat + Untuk memulai obrolan baru + Video + Koneksi yang Anda terima akan dibatalkan! + Kode QR ini bukan tautan! + Bunyikan + Isolasi transport + Perbarui mode isolasi transport? + Untuk melindungi alamat IP, routing pribadi menggunakan server SMP untuk mengirim pesan. + Perlihat kesalahan internal + Lihat panggilan API lambat + Kami tidak menyimpan kontak atau pesan Anda (setelah terkirim) di server. + Profil, kontak, dan pesan terkirim Anda disimpan di perangkat Anda. + Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. + Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. + PROXY SOCKS + Alihkan daftar obrolan: + Tingkatkan dan buka obrolan + Ketuk untuk gabung ke samaran + Anda memblokir %s + %s, %s dan %d anggota lainnya terhubung + Grup ini memiliki lebih dari %1$d anggota, tanda terima kiriman tidak dikirimkan. + Permintaan koneksi akan dikirim ke anggota grup ini. + Perlihat profil obrolan + Profil random Anda + %s diunggah + Aktifkan log + Profil hanya dibagikan dengan kontak Anda. + Ekor + Berhenti + Tindakan ini tidak dapat dibatalkan - profil, kontak, pesan, dan berkas Anda akan hilang secara permanen. + Beberapa kesalahan tidak fatal terjadi selama impor: + Anda dapat menyimpan arsip yang diekspor. + Terima kasih kepada pengguna – kontribusi via Weblate! + Ini adalah tautan 1-kali milik Anda! + Pesan akan ditandai sebagai dimoderasi untuk semua anggota. + Pesan akan dihapus untuk semua anggota. + Pesan akan ditandai sebagai dimoderasi untuk semua anggota. + Video akan diterima saat kontak Anda selesai mengunggah. + Kontak Anda mengirim berkas yang lebih besar dari ukuran maksimal (%1$s). + Kesalahan berkas sementara + Pesan suara… + Anda menerima koneksi + Anda harus menggunakan versi terbaru basis data obrolan Anda pada satu perangkat SAJA, jika tidak, Anda mungkin berhenti menerima pesan dari beberapa kontak. + Beberapa berkas tidak diekspor + Anda dapat memindahkan basis data yang diekspor. + Frasa sandi akan disimpan dalam pengaturan sebagai teks biasa setelah Anda mengubahnya atau memulai ulang aplikasi. + Upaya untuk mengubah frasa sandi basis data tidak selesai. + Waktu koneksi TCP habis + Terima kasih kepada pengguna – kontribusi via Weblate! + Arsip basis data yang diunggah akan dihapus secara permanen dari server. + Peringatan: memulai obrolan di beberapa perangkat tidak didukung dan akan menyebabkan gagal kirim pesan + Verifikasi frasa sandi + Kesalahan unggah + Setel frasa sandi + Tindakan ini tidak dapat dibatalkan - pesan yang dikirim dan diterima sebelum waktu yang dipilih akan dihapus. Mungkin perlu waktu beberapa menit. + Anda dapat ubah di pengaturan Tampilan. + Grup ini tidak ada lagi. + Anda menolak undangan grup + Anda mengubah hak Anda menjadi %s + Anda mengubah hak %s menjadi %s + Lihat ketentuan + Server untuk berkas baru dari profil obrolan Anda saat ini + Perbarui aplikasi secara otomatis + Operator prasetel kedua dalam aplikasi! + Lihat ketentuan yang diperbarui + Nama perangkat akan dibagikan dengan klien seluler yang terhubung. + Batas waktu tercapai saat menghubungkan ke desktop + Anda sudah terhubung melalui tautan 1-kali ini! + Gunakan dari desktop + Tindakan ini tidak dapat dibatalkan - semua berkas dan media yang diterima dan dikirim akan dihapus. Gambar beresolusi rendah akan tetap ada. + Hak akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. + Basis data obrolan Anda tidak dienkripsi - setel frasa sandi untuk melindunginya. + Anda diundang ke grup + Menunggu ponsel terhubung: + Setel frasa sandi basis data + Perbarui + Perbarui frasa sandi basis data + Anda menghapus %1$s + %s, %s dan %d anggota + %s: %s + Perbarui + Verifikasi koneksi + Berlangganan + Gagal simpan basis data + Frasa sandi disimpan di pengaturan sebagai teks biasa. + Video tidak dapat didekodekan. Silakan coba video lain atau hubungi pengembang. + Enkripsi berfungsi dan perjanjian enkripsi baru tidak diperlukan. Hal ini dapat mengakibatkan kesalahan koneksi! + Bagikan alamat secara publik + Bagikan alamat SimpleX di media sosial. + Bagikan tautan 1-kali dengan teman + Alamat SimpleX dan tautan 1-kali aman untuk dibagikan ke messenger lain. + Anda dapat gunakan markdown untuk format pesan: + Anda akan berhenti menerima pesan dari obrolan ini. Riwayat obrolan akan disimpan. + Gunakan untuk pesan + Gunakan untuk berkas + Koneksi TCP + Anda masih akan menerima panggilan dan notifikasi dari profil yang dibisukan ketika profil tersebut aktif. + Saat Anda berbagi profil samaran dengan seseorang, profil ini akan digunakan untuk grup tempat Anda diundang. + Tautan ini digunakan dengan perangkat seluler lain, silakan buat tautan baru di desktop. + Kontak Anda akan tetap terhubung. + Putuskan desktop? + Peringatan: Anda akan kehilangan beberapa data! + Kesalahan berlangganan + Pesan akan dihapus untuk semua anggota. + Verifikasi kode di ponsel + Anda diundang ke grup. Bergabung untuk terhubung dengan anggota grup. + Profil Anda disimpan di perangkat dan hanya dibagikan dengan kontak. Server SimpleX tidak dapat melihat profil Anda. + Anda mengubah alamat untuk %s + profil grup diperbarui + %s, %s dan %s terhubung + Hak akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. + minggu + Gagal unggah + Mengunggah arsip + Anda tidak terhubung ke server ini. Routing pribadi digunakan untuk kirim pesan ke server ini. + Video akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Verifikasi kode keamanan + Anda masih dapat melihat percakapan dengan %1$s dalam daftar obrolan. + Pesan suara dilarang! + Tombol ketuk + Batal favorit + Anda mengundang kontak + Alamat SimpleX + ingin terhubung dengan Anda! + Lihat kode QR + Bagikan alamat dengan kontak? + Basis data obrolan Anda saat ini akan DIHAPUS dan DIGANTI dengan yang diimpor.\nTindakan ini tidak dapat dibatalkan - profil, kontak, pesan, dan berkas Anda akan hilang secara permanen. + Kesalahan tidak diketahui + Kesalahan basis data tidak diketahui: %s + Frase sandi salah! + Anda telah meminta koneksi melalui alamat ini! + Hentikan obrolan + Anda tidak dapat kirim pesan! + Pesan suara (%1$s) + Lihat kode keamanan + Anda perlu mengizinkan kontak mengirim pesan suara agar dapat mengirimkannya. + (untuk dibagikan dengan kontak Anda) + Ketuk untuk pindai + Terima kasih telah memasang SimpleX Chat! + Setel nama kontak + Kontak Anda harus online agar koneksi dapat selesai.\nAnda dapat batalkan koneksi ini dan hapus kontak (dan coba lagi nanti dengan tautan baru). + Logo SimpleX + Tim SimpleX + Anda akan terhubung saat perangkat kontak Anda online, harap tunggu atau periksa nanti! + Anda akan terhubung saat permintaan koneksi Anda diterima. Harap tunggu atau periksa nanti! + Bagikan tautan 1-kali + Saat orang meminta untuk terhubung, Anda dapat terima atau menolaknya. + Anda tidak akan kehilangan kontak jika menghapus alamat Anda nanti. + Matikan? + Hentikan obrolan? + Anda bergabung ke grup ini + Hentikan obrolan untuk ekspor, impor, atau hapus basis data obrolan. Anda tidak akan dapat terima dan kirim pesan saat obrolan dihentikan. + Pengaturan ini berlaku untuk pesan di profil obrolan Anda saat ini + Basis data obrolan Anda + buka blokir %s + Anda mengubah alamat + Untuk kirim + Menghentikan obrolan + Perlihat profil + Anda harus masukkan frasa sandi setiap aplikasi dibuka - frasa sandi tidak disimpan di perangkat. + Server SMP + Profil obrolan Anda akan dikirim\nke kontak Anda + Bagikan alamat + Hak akan diubah menjadi %s. Anggota akan menerima undangan baru. + Perbarui pengaturan jaringan? + Ketuk untuk aktifkan profil. + Mulai dari %s. + Anda akan terhubung ke grup saat perangkat pemilik grup sedang online, harap tunggu atau periksa nanti! + Lihat: + Frasa sandi basis data salah + setel foto profil baru + profil diperbarui + Ukuran + Berkas diunggah + Koneksi mencapai batas pesan yang tidak terkirim, kontak Anda mungkin sedang offline. + Kunci SimpleX tidak diaktifkan! + Anda dapat aktifkan Kunci SimpleX di Pengaturan. + Kontak yang Anda bagikan tautan ini TIDAK akan dapat terhubung! + Gambar tidak dapat didekodekan. Silakan coba gambar lain atau hubungi pengembang. + Kunci salah atau koneksi tidak dikenal - kemungkinan besar koneksi ini dihapus. + Mulai obrolan baru + Ketuk Buat alamat SimpleX di menu untuk membuatnya nanti. + Untuk terhubung, kontak Anda dapat pindai kode QR atau gunakan tautan di aplikasi. + Profil Anda %1$s akan dibagikan. + Untuk melindungi tautan Anda dari penggantian, Anda dapat membandingkan kode keamanan kontak. + Pembaruan unduhan dibatalkan + Profil Anda saat ini + Anda menggunakan profil samaran untuk grup ini - untuk mencegah berbagi profil utama Anda, undang kontak tidak diizinkan + Lihat daftar obrolan di jendela baru + Anda mengirim undangan grup + Tautan ini bukan tautan koneksi yang valid! + Anda akan berhenti menerima pesan dari grup ini. Riwayat obrolan akan disimpan. + Anda bergabung ke grup ini. Menghubungkan untuk undang anggota grup. + Anda buka blokir %s + Menunggu desktop… + Ganti profil obrolan untuk undangan 1-kali. + Untuk perlihat profil tersembunyi Anda, masukkan kata sandi lengkap di kolom pencarian di halaman Profil obrolan Anda. + Anda mengendalikan obrolan Anda! + Untuk terima + Perlihat + Perbarui pengaturan akan menghubungkan ulang klien ke semua server. + Verifikasi frasa sandi basis data + Server XFTP + Langganan diabaikan + Berhenti berbagi + Berhenti berbagi alamat? + Tampilkan opsi pengembang + Untuk mengizinkan aplikasi seluler terhubung ke desktop, buka port ini di firewall Anda, jika Anda mengaktifkannya + coret + Hapus pesan setelah + a + b + Hapus obrolan profil untuk + Koneksi belum siap + Kesalahan pada pembuatan daftar percakapan + Kesalahan memuat daftar percakapan + Kesalahan memperbaharui daftar percakapan + Daftar + Kontak + Favorit + Tidak ada pesan + Tidak ditemukan pesan + Tidak ada pesan dalam daftar %s + Tidak ada chat belum terbaca + Tambahkan daftar + Bisnis + Grup + Buka dengan %s + Tambahkan dalam daftar + Buat daftar + Daftar nama + Daftar nama dan emoji harus berbeda untuk semua daftar + Simpan daftar + Semua percakapan akan dipindahkan dari daftar %s, dan daftar akan dihapus + Hapus + Hapus daftar + Sunting + Semua + 1 laporan + Arsip + Alasan lain + laporan arsip oleh %s + Arsip laporan + Hapus laporan + Laporan + Laporkan lainnya: hanya moderator grup yang akan melihat. + Gagal membuat laporan + Koneksi diblokir + Koneksi diblokir oleh operator server:\n%1$s. + Konten melanggar ketentuan penggunaan + Spam + Alasan laporan? + Laporkan + Laporan Anggota + Catatan + Laporkan spam: hanya moderator grup yang akan melihat. + Laporkan pelanggaran: hanya moderator grup yang akan melihat. + Gagal simpan pengaturan + %d laporan + Laporkan profil anggota: hanya moderator grup yang akan melihat. + Arsip laporan? + Berkas diblokir oleh operator server:\n\n%1$s. + Laporan akan diarsipkan untuk Anda. + moderator + Laporkan konten: hanya moderator grup yang akan melihat. + Ubah daftar + Ubah urutan + laporan arsip + Pelanggaran pedoman komunitas + Konten tidak pantas + Profil tidak pantas + Hanya pengirim dan moderator dapat melihat + Hanya Anda dan moderator dapat melihat + Spam + Setel nama obrolan… + Buka tautan peramban? + Buka tautan + Ubah hapus pesan otomatis? + Matikan hapus pesan otomatis? + Pesan dalam obrolan ini tidak akan pernah dihapus. + Hapus pesan obrolan dari perangkat Anda. + Matikan hapus pesan + Tindakan tak dapat dibatalkan - pesan yang dikirim dan diterima dalam obrolan ini sebelum yang dipilih akan dihapus. + Tanya + Tidak + Buka tautan dari daftar obrolan + Ya + 1 tahun + bawaan (%s) + Bisukan semua + Sebutan belum terbaca + Port TCP untuk pesan + Gunakan port TCP %1$s jika tidak ada port yang ditentukan. + Gunakan port peramban + Anda dapat menyebut hingga %1$s anggota per pesan! + Semua laporan akan diarsipkan untuk Anda. + Arsipkan semua laporan? + Untuk semua moderator + Untuk saya + Arsip laporan + Laporan: %s + Izinkan untuk laporkan pesan ke moderator. + Anggota dapat melaporkan pesan ke moderator. + Laporkan pesan dilarang di grup ini. + Dilarang laporkan pesan ke moderator. + Arsipkan %d laporan? + Gagal membaca frasa sandi basis data + Privasi dan keamanan lebih baik + Semua pesan baru dari anggota ini akan disembunyikan! + Blokir anggota untuk semua? + Dapat notifikasi saat disebut. + Kirim pesan lebih cepat. + Jangan lewatkan pesan penting. + Kinerja grup yang lebih baik + Hapus grup lebih cepat. + Syarat diperbarui + Anggota akan dihapus dari obrolan - hal ini tidak dapat dibatalkan! + Hapus anggota? + ditolak + tertunda + menunggu persetujuan + Buka blokir anggota untuk semua? + Kirim laporan pribadi + Bantu admin memoderasi grup. + Atur pesan kedaluwarsa obrolan. + Nama berkas media pribadi. + ditolak + Anggota akan dihapus dari grup - tindakan ini tidak dapat dibatalkan! + moderator + Pesan dari anggota ini akan ditampilkan! + Sebutkan anggota 👋 + Atur obrolan ke dalam daftar + Frasa sandi di Keystore tidak dapat dibaca. Hal ini mungkin terjadi setelah pembaruan sistem yang tidak kompatibel dengan aplikasi. Jika tidak demikian, silakan hubungi pengembang. + Terima + Dengan menggunakan SimpleX Chat, Anda setuju untuk:\n- hanya mengirim konten legal di grup publik.\n- hormati pengguna lain – tidak ada spam. + Konfigurasikan operator server + Kebijakan privasi dan ketentuan penggunaan. + Obrolan pribadi, grup, dan kontak Anda tidak dapat diakses oleh operator server. + Frasa sandi di Keystore tidak dapat dibaca, silakan masukkan secara manual. Hal ini mungkin terjadi setelah pembaruan sistem yang tidak kompatibel dengan aplikasi. Jika tidak demikian, silakan hubungi pengembang. + Gunakan port TCP 443 hanya untuk presetel server. + Semua server + Mati + Presetel server + Tautan lengkap + Tautan ini perlu versi aplikasi yang baru. Harap perbarui aplikasi atau minta kontak untuk kirim tautan kompatibel. + Tautan saluran SimpleX + Gunakan tautan singkat (BETA) + Tautan koneksi tidak didukung + Tautan singkat + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index ffdc377ceb..d15729be18 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -266,7 +266,7 @@ Nome completo del gruppo: scansionare il codice QR nella videochiamata, oppure il tuo contatto può condividere un link di invito.]]> Backup dei dati dell\'app - Android Keystore è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. + L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. Database della chat eliminato ICONA APP @@ -274,7 +274,7 @@ Consuma più batteria! L\'app funziona sempre in secondo piano: le notifiche vengono mostrate istantaneamente.]]> chiamata… annulla anteprima link - Impossibile accedere al Keystore per salvare la password del database + Impossibile accedere all\'archivio chiavi per salvare la password del database Impossibile invitare i contatti! Cambia ruolo cambio indirizzo… @@ -285,7 +285,7 @@ Elimina link Crea indirizzo Crea link - La password di crittografia del database verrà aggiornata e conservata nel Keystore. + La password di crittografia del database verrà aggiornata e conservata nell\'archivio chiavi. Il database è crittografato con una password casuale, puoi cambiarla. La password del database è necessaria per aprire la chat. Elimina @@ -399,7 +399,7 @@ AIUTO Chat fermata Errore del database - La password del database è diversa da quella salvata nel Keystore. + La password del database è diversa da quella salvata nell\'archivio chiavi. Database crittografato Inserisci la password giusta. Inserisci la password… @@ -497,7 +497,7 @@ Confronta i codici di sicurezza con i tuoi contatti. Messaggi a tempo Nascondi la schermata dell\'app nelle app recenti. - Android Keystore verrà usato per memorizzare in modo sicuro la password dopo il riavvio dell\'app o la modifica della password; consentirà di ricevere le notifiche. + L\'archivio chiavi di Android verrà usato per memorizzare in modo sicuro la password dopo il riavvio dell\'app o la modifica della password; consentirà di ricevere le notifiche. Nota bene: NON potrai recuperare o cambiare la password se la perdi.]]> Cambiare password del database\? Conferma nuova password… @@ -505,7 +505,7 @@ Database crittografato! La password di crittografia del database verrà aggiornata. Il database verrà crittografato. - Il database verrà crittografato e la password conservata nel Keystore. + Il database verrà crittografato e la password conservata nell\'archivio chiavi. Eliminare i file e i multimediali\? Elimina messaggi Elimina messaggi dopo @@ -729,7 +729,7 @@ Esci Uscire dal gruppo\? Apri chat - Password non trovata nel Keystore, inseriscila a mano. Potrebbe essere successo se hai ripristinato i dati dell\'app usando uno strumento di backup. In caso contrario, contatta gli sviluppatori. + Password non trovata nell\'archivio chiavi, inseriscila a mano. Potrebbe essere successo se hai ripristinato i dati dell\'app usando uno strumento di backup. In caso contrario, contatta gli sviluppatori. Inserisci la password precedente dopo aver ripristinato il backup del database. Questa azione non può essere annullata. Conserva la password in modo sicuro, NON potrai accedere alla chat se la perdi. Ripristina @@ -871,8 +871,8 @@ Inserisci la password attuale corretta. Conserva la password in modo sicuro, NON potrai cambiarla se la perdi. Rimuovi - Rimuovere la password dal Keystore\? - Salva la password nel Keystore + Rimuovere la password dall\'archivio chiavi? + Salva la password nell\'archivio chiavi %s secondo/i Questa azione non può essere annullata: tutti i file e i media ricevuti e inviati verranno eliminati. Rimarranno le immagini a bassa risoluzione. Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti. @@ -940,7 +940,7 @@ Errore nell\'aggiornamento del link del gruppo osservatore Contatta l\'amministratore del gruppo. - Non puoi inviare messaggi! + sei un osservatore Sistema Aggiungi messaggio di benvenuto Messaggio di benvenuto @@ -1586,7 +1586,7 @@ Errore di eliminazione delle note private Consegna dei messaggi migliorata Entra in conversazioni di gruppo - membro %1$s cambiato in %2$s + il membro %1$s è diventato %2$s Incolla un link per connettere! indirizzo di contatto rimosso contatto %1$s cambiato in %2$s @@ -1947,8 +1947,7 @@ Dimensione carattere Totale inviato Messaggio inoltrato - Inizio da %s. -\nTutti i dati sono privati, nel tuo dispositivo. + Partendo da %s. \nTutti i dati sono privati, nel tuo dispositivo. Ancora nessuna connessione diretta, il messaggio viene inoltrato dall\'amministratore. Non sei connesso/a a questi server. L\'instradamento privato è usato per consegnare loro i messaggi. Altri server XFTP @@ -2178,7 +2177,7 @@ Trasparenza Decentralizzazione della rete Il secondo operatore preimpostato nell\'app! - Attiva Flux + Attiva Flux nelle impostazioni \"Rete e server\" per una migliore privacy dei metadati. Vedi le condizioni aggiornate Sfocatura Server dei messaggi aggiunti @@ -2248,5 +2247,196 @@ Quando più di un operatore è attivato, nessuno di essi ha metadati per capire chi comunica con chi. invito accettato richiesto di connettersi - SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell\'app - \ No newline at end of file + SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell\'app. + Info sugli operatori + La connessione richiede la rinegoziazione della crittografia. + Correggi + Correggere la connessione? + Rinegoziazione della crittografia in corso. + Attiva i log + Errore di salvataggio del database + Connessione non pronta. + Errore di aggiornamento dell\'elenco di chat + Tutte + Preferite + Gruppi + Nessuna chat + Nessuna chat trovata + Nessuna chat non letta + Aggiungi elenco + Apri con %s + Aggiungi ad un elenco + Nome elenco... + Salva elenco + Elimina + Tutte le chat verranno rimosse dall\'elenco %s, e l\'elenco eliminato + Eliminare l\'elenco? + Il nome dell\'elenco e l\'emoji dovrebbero essere diversi per tutte le liste. + Errore di caricamento dell\'elenco di chat + Errore di creazione dell\'elenco di chat + Elenco + Contatti + Lavorative + Crea elenco + Modifica + Nessuna chat nell\'elenco %s. + Cambia elenco + Note + Cambia ordine + Errore di salvataggio delle impostazioni + Errore nella creazione del resoconto + Archiviare la segnalazione? + segnalazione archiviata + Segnala contenuto: solo i moderatori del gruppo lo vedranno. + Archivia + Altro motivo + Segnala altro: solo i moderatori del gruppo lo vedranno. + Violazione delle linee guida della comunità + Contenuto inappropriato + Profilo inappropriato + Solo il mittente e i moderatori lo vedono + Solo tu e i moderatori lo vedete + Spam + Segnala + Motivo della segnalazione? + La segnalazione verrà archiviata per te. + Segnala profilo: solo i moderatori del gruppo lo vedranno. + Segnala spam: solo i moderatori del gruppo lo vedranno. + Segnala violazione: solo i moderatori del gruppo lo vedranno. + moderatore + Archivia la segnalazione + Elimina la segnalazione + Segnalazioni + 1 segnalazione + segnalazione archiviata da %s + %d segnalazioni + Segnalazioni dei membri + Spam + Connessione bloccata + Il file è bloccato dall\'operatore del server:\n%1$s. + La connessione è bloccata dall\'operatore del server:\n%1$s. + Il contenuto viola le condizioni di utilizzo + Aprire il link? + + Apri i link dall\'elenco delle chat + No + Apri link + Chiedi + Imposta il nome della chat… + Disattiva eliminazione messaggi + 1 anno + Disattivare l\'eliminazione automatica dei messaggi? + Cambiare l\'eliminazione automatica dei messaggi? + Questa azione non è reversibile: i messaggi inviati e ricevuti in questa chat prima della selezione verranno eliminati. + Elimina i messaggi di chat dal tuo dispositivo. + I messaggi in questa chat non verranno mai eliminati. + predefinito (%s) + Usa la porta TCP %1$s quando nessuna porta è specificata. + Porta TCP per i messaggi + Usa porta web + Silenzia tutto + Menzioni non lette + Puoi menzionare fino a %1$s membri per messaggio! + I membri possono segnalare messaggi ai moderatori. + Archiviare tutte le segnalazioni? + Archiviare %d segnalazioni? + Archivia segnalazioni + Per tutti i moderatori + Per me + Segnalazione: %s + In questo gruppo è vietato segnalare messaggi. + Consenti di segnalare messaggi ai moderatori. + Tutte le segnalazioni verranno archiviate per te. + Vieta di segnalare messaggi ai moderatori. + Non perdere messaggi importanti. + Eliminazione dei gruppi più veloce. + Ricevi una notifica quando menzionato. + Aiuta gli amministratori a moderare i loro gruppi. + Menziona i membri 👋 + Privacy e sicurezza migliori + Invio dei messaggi più veloce. + Prestazioni dei gruppi migliorate + Organizza le chat in elenchi + Imposta la scadenza dei messaggi nelle chat. + Invia segnalazioni private + Nomi privati dei file multimediali. + rifiutato + rifiutato + Errore di lettura della password del database + in attesa di approvazione + in attesa + Condizioni aggiornate + La password nell\'archivio chiavi non può essere letta. Potrebbe essere successo dopo un aggiornamento di sistema incompatibile con l\'app. In caso contrario, contatta gli sviluppatori. + La password nell\'archivio chiavi non può essere letta, inseriscila a mano. Potrebbe essere successo dopo un aggiornamento di sistema incompatibile con l\'app. In caso contrario, contatta gli sviluppatori. + I membri verranno rimossi dalla chat, non è reversibile! + I membri verranno rimossi dal gruppo, non è reversibile! + Rimuovere i membri? + I messaggi di questi membri verranno mostrati! + Sbloccare i membri per tutti? + Bloccare i membri per tutti? + moderatori + Tutti i nuovi messaggi di questi membri verranno nascosti! + Usando SimpleX Chat accetti di:\n- inviare solo contenuto legale nei gruppi pubblici.\n- rispettare gli altri utenti - niente spam. + Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server. + Accetta + Configura gli operatori dei server + Informativa sulla privacy e condizioni d\'uso. + Questo link richiede una versione più recente dell\'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile. + Link completo + Link breve + Link del canale SimpleX + Link di connessione non supportato + Usa link brevi (BETA) + Tutti i server + Off + Server preimpostati + Usa la porta TCP 443 solo per i server preimpostati. + 1 chat con un membro + %d chat + %d chat con membri + %d messaggi + Salvare le impostazioni di ammissione? + ha accettato %1$s + ti ha accettato/a + Attendi che i moderatori del gruppo revisionino la tua richiesta di entrare nel gruppo. + hai accettato questo membro + revisiona + Ammissione del membro + Nessuna chat con membri + off + Revisiona i membri + Revisiona i membri prima di ammetterli (bussare). + Accetta + Chat con amministratori + Rimuovi + Il membro entrerà nel gruppo, accettarlo? + revisionato dagli amministratori + Accetta membro + Chatta con gli amministratori + Accetta come osservatore + Un nuovo membro vuole entrare nel gruppo. + tutti + Chatta con il membro + Chat con membri + Errore di accettazione del membro + Accetta come membro + Imposta l\'ammissione dei membri + Segnalazione inviata ai moderatori + in attesa di revisione + Puoi vedere le tue segnalazioni nella chat con gli amministratori. + Non puoi inviare messaggi! + contatto non pronto + contatto eliminato + contatto disattivato + non sincronizzato + richiesta di entrare rifiutata + impossibile inviare messaggi + il gruppo è eliminato + il membro ha una versione vecchia + rimosso dal gruppo + sei uscito/a + Eliminare la chat con il membro? + Rifiutare il membro? + Elimina chat + Errore di eliminazione della chat con il membro + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 2bb007b6e8..1103e3a0e6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -371,7 +371,7 @@ מופעל מופעל עבור איש הקשר מופעל עבורך - הודעות נעלמות אסורות בקבוצה זו. + הודעות נעלמות אסורות. %d דקה %d שנ׳ הודעות נעלמות @@ -489,10 +489,10 @@ הקבוצה תימחק עבורך – לא ניתן לבטל זאת! הסתר העדפות קבוצה - חברי קבוצה יכולים למחוק הודעות שנשלחו באופן בלתי הפיך. (24 שעות) - חברי הקבוצה יכולים לשלוח הודעות נעלמות. - חברי הקבוצה יכולים לשלוח הודעות ישירות. - חברי הקבוצה יכולים לשלוח הודעות קוליות. + משתמשים יכולים למחוק הודעות שנשלחו באופן בלתי הפיך. (24 שעות) + משתמשים יכולים לשלוח הודעות נעלמות. + משתמשים יכולים לשלוח הודעות ישירות. + יכולים לשלוח הודעות קוליות. אפשר השמדה עצמית אם תבחרו לדחות השולח לא יקבל התראה על כך. אם תאשרו, שרתי העברת ההודעות יוכלו לראות את ה־IP שלכם, וספק האינטרנט שלכם – את השרתים אליהם אתם מחוברים. @@ -505,7 +505,7 @@ התעלם מיד ייבא מסד נתונים - חסין מפני ספאם ושימוש לרעה + חסין מפני ספאם לייבא מסד נתונים של צ׳אט\? תמונה נשלחה התמונה תתקבל כאשר איש הקשר יסיים להעלות אותה. @@ -521,10 +521,10 @@ זהות נסתרת באמצעות קישור קבוצה זהות נסתרת באמצעות קישור חד־פעמי קישור חיבור לא תקין - אפשרו ל-SimpleX לפעול ברקע בתיבת הדו-שיח הבאה. אחרת, ההתראות יושבתו.]]> + אפשר זאת בתיבת הדו-שיח הבאה כדי לקבל התראות על הודעות חדשות באופן מיידי.]]> התראות מיידיות מושבתות! הזמן חברי קבוצה - הוזמן + הזמין את עקיף (%1$s) מצב זהות נסתרת מגן על הפרטיות שלך על ידי שימוש בפרופיל אקראי חדש עבור כל איש קשר. גרסת מסד נתונים לא תואמת @@ -556,14 +556,14 @@ הזמן לקבוצה הזמן חברי קבוצה מחיקה בלתי הפיכה של הודעות אסורה בצ׳אט זה. - מחיקה בלתי הפיכה של הודעות אסורה בקבוצה זו. + מחיקת הודעות בלתי הפיכה אסורה. להצטרף בתור %s זה מאפשר חיבורים אנונימיים רבים ללא שום נתונים משותפים ביניהם בפרופיל צ׳אט יחיד. זה יכול לקרות כאשר: \n1. פג תוקפן של ההודעות בלקוח השולח לאחר 2 ימים או בשרת לאחר 30 ימים. \n2. פיענוח הצפנת הודעה נכשל, מכיוון שאתם או איש הקשר שלכם השתמשתם בגיבוי ישן של מסד הנתונים. \n3. החיבור נפגע. - ניתן לשנות זאת מאוחר יותר באמצעות ההגדרות. + איך זה משפיע על הסוללה זה יכול לקרות כאשר אתם או איש הקשר שלכם השתמשתם בגיבוי ישן של מסד הנתונים. להצטרף לקבוצה\? הצטרף @@ -608,7 +608,7 @@ קישור הזמנה חד־פעמי מרקדאון בהודעות רשת ושרתים - הגדרות רשת + הגדרות מתקדמות ארכיון מסד נתונים חדש הודעות חבר קבוצה @@ -690,7 +690,7 @@ תגובות אמוג׳י להודעות אסורות בקבוצה זו. אפשר לאנשי הקשר להוסיף תגובות אמוג׳י להודעות. אפשר תגובות אמוג׳י להודעות רק אם איש הקשר מאפשר אותן. - חברי הקבוצה יכולים להוסיף תגובות אמוג׳י להודעות. + משתמשים יכולים להוסיף תגובות אמוג׳י להודעות. רק אתם יכולים להוסיף תגובות אמוג׳י להודעות. רק איש הקשר שלכם יכול להוסיף תגובות אמוג׳י להודעות. פתח @@ -737,7 +737,7 @@ שרת מוגדר מראש פרטיות מוגדרת מחדש אנשים יכולים להתחבר אליכם רק דרך הקישורים שאתם משתפים. - פרוטוקול וקוד פתוחים – כל אחד יכול להריץ את השרתים. + כל אחד יכול לארח שרתים. תקופתי נא להזין את הסיסמה הקודמת לאחר שחזור גיבוי מסד הנתונים, לא ניתן לבטל פעולה זו. לאסור מחיקה בלתי הפיכה של הודעות. @@ -746,7 +746,7 @@ אנא בידקו את חיבור האינטרנט שלכם עם %1$s ונסו שוב. ייתכן שטביעת האצבע של התעודה בכתובת השרת שגויה פתיחת מסוף צ׳אט - פתיחת פרופילי צ׳אט + שנה פרופילי צ׳אט ממתין כתובת שרת מוגדר מראש סיסמה להצגה @@ -1016,7 +1016,7 @@ יותר מדי תמונות! תודה שהתקנתם את SimpleX Chat! קישור זה אינו קישור חיבור תקין! - צבעי ערכת נושא + צבעי ממשק התפקיד ישתנה ל־"%s". החבר יקבל הזמנה חדשה. השרתים לחיבורים חדשים של פרופיל הצ׳אט הנוכחי שלך הפלטפורמה הראשונה ללא כל מזהי משתמש - פרטית בעיצובה. @@ -1054,7 +1054,7 @@ שדרג ופתח צ׳אט כדי להגן על אזור הזמן, קובצי תמונה/קול משתמשים ב־UTC. העלה קובץ - שירות רקע SimpleX – הוא משתמש בכמה אחוזים מהסוללה ביום.]]> + SimpleX רץ ברקע במקום להשתמש בpush notifications.]]> כדי לקבל התראות, יש להזין את סיסמת מסד הנתונים בטל נעילה שליחה לא מורשית @@ -1239,15 +1239,15 @@ כיבוי אפליקציה אפשר לשלוח קבצים ומדיה. - מועדף + הוסף למועדפים קבצים ומדיה אין צ\'אטים מסוננים לכבות\? קבצים ומדיה אסורים! - קבצים ומדיה אסורים בקבוצה זו. - חברי הקבוצה יכולים לשלוח קבצים ומדיה. + קבצים ומדיה אסורים. + משתמשים יכולים לשלוח קבצים ומדיה. איתחול - שנוא + הסר מהמועדפים כבוי קו חוצה לאסור שליחת קבצים ומדיה. @@ -1528,8 +1528,8 @@ הצג קריאות API איטיות אפשרויות למפתח צור פרופיל - ו %d שאר האירועים - הגדר כתובת איש קשר חדש + בנוסף ל- %d אירועים אחרים + איש הקשר הגדיר כתובת חדשה לחץ לחיבור דפדפן האינטרנט המוגדר כברירת מחדל נדרש לשיחות. אנא הגדר דפדפן ברירת מחדל במערכת, ושתף מידע נוסף עם המפתחים. השיחה הזו מוגנת באמצעות הצפנה קצה-אל-קצה. @@ -1609,7 +1609,7 @@ החיבור עצר נתיב קובץ לא חוקי שיתפת נתיב קובץ לא חוקי. דווח על הבעיה למפתחי האפליקציה. - %1$d הודעות שנערכו על ידי %2$s + %1$d הודעות נחסמו על ידי %2$s %d הודעות סומנו כנמחקות האם לחזור על בקשת החיבור? חסום @@ -1632,13 +1632,13 @@ טעינה של הקובץ שימוש ממחשב שולחני חסומים %s - מחק איש קשר + איש קשר נמחק %d אירועי קבוצה %s, %s ו-%d חברים איש הקשר %1$s השתנה ל-%2$s כתובת איש קשר הוסרה תמונת פרופיל הוסרה - הגדר תמונת פרופיל חדשה + הגדיר תמונת פרופיל חדשה עדכן פרופיל מצב לא ידוע נוצר ב @@ -1722,7 +1722,7 @@ מרובע, עיגול, או כל דבר ביניהם העבר ושמור הודעות מתי שמתחבר שחיות קוליות ווידאו. - לא מצליח לשלוח הודעה + לא ניתן לשלוח הודעה הודעות קוליות לא מאופשרות שגיאת קובץ זמני קבצים ומדיה לא מאופשרים @@ -1748,7 +1748,7 @@ מצב הקובץ:%s ריק כהה - מצב צבעוני + ערכת נושא שחור בהיר אפס צבע @@ -1767,8 +1767,8 @@ הגדר ערכת נושא ברירת מחדל אפס ערכת נושא למשתמש החל ל - מצב כל הצבעים - חברי הקבוצה יכולים לשלוח קישורי SimpleX + ערכת נושא + משתמשים יכולים לשלוח קישורי SimpleXצ עשה שהצאט\'ים שלך יראו אחרת! הגדרות רשת הקישור הזה שומש כבר במכשיר אחר, אנא צור קישור חדש במחשב. @@ -1918,4 +1918,177 @@ שרת XFTP חלש אנשי קשר בארכיון - \ No newline at end of file + דיווח בארכיון + הפרה של הנחיות קהילתיות + %1$d שגיאת קבצים:\n%2$s + %1$d הקבצים עדיין בהורדה. + הסכם לתנאים + %1$d ההורדה של הקובץ/ים עדיין לא הסתיימה. + התנאים המקובלים עלי + שנה + סיבה אחרת + כתובת עסקית + שיפור בסידור של הודעות לפי תאריכים. + שיפור ביצועים לקבוצות + התקשר + לא ניתן להתקשר לחבר קבוצה + %s.]]> + להעביר דיווח לארכיון? + מסד נתונים של הצא\'טים + בדוק עבור הודעות חדשות כל 10 דקות + אישרת את תנאי השימוש ב:%s. + הצ\'אט יימחק עבור כל החברים - לא ניתן לבטל את זה! + לחץ על כפתור מידע ליד שדה כתובת כדי לאפשר שימוש במיקרופון. + %s.]]> + %s.]]> + העבר אנשי קשר לארכיון לשוחח מאוחר יותר + לשנות את מחיקת ההודעה האוטומטית? + הצ\'אט כבר קיים! + הכל + %1$d שגיאה/ות קובץ אחר/ות. + %1$d הקובץ/ים נכשל/ו בהורדה. + כל ההודעות החדשות מחברים אלו יוסתרו! + לחסום את חברי הקבוצה לכולם? + הדיווח הועבר לארכיון ע\"י %s + עם איש קשר אחד בלבד - שתף באופן אישי או באמצעות כל מסנג\'ר.]]> + טשטוש בשביל שיפור הפרטיות. + דיווח 1 + כל הצ\'אטים יוסרו מהרשימת %s, והרשימה תימחק + שנה רשימה + לאפשר שיחות? + הצ\'אט יימחק עבורך - אי אפשר לבטל את זה! + שיחות לא מורשות! + שאל + %s.]]> + מכשירי שיואמי: אנא תאפשר הפעלה אוטומטית בהגדרות הטלפון שלך כדי שההתראות על הודעות חדשות יפעלו.]]> + %1$s ההודעות לא הועברו. + מוצפנים מקצה לקצה, עם אבטחה פוסט-קוונטית בהודעות ישירות.]]> + טישטוש + עסקי + %s, קבל את תנאי השימוש.]]> + כל הדיווחים אצלך יועברו לארכיון. + להעביר לארכיון %d דיווחים? + להעביר לארכיון את כל הדיווחים? + העבר דיווח לארכיון + דיווחים בארכיון + אפשר לדווח על הודעות למנהלים. + %1$d הקובץ/ים נמחקו. + %s.]]> + המסד נתונים יוצא בהצלחה + %1$s כבר באנשי קשר.]]> + לא ניתן לשלוח הודעה לחבר קבוצה + קבל הזמנה + על המפעילים + לא ניתן להתקשר לאיש קשר + שיפור לשיחות + שנה את הסדר + ארכיון + אני מסכים + שיפור בפרטיות ובאבטחה + אבטחה יותר טובה✅ + ממשק משתמש יותר נוח + "גרסאת שרת היעד %1$s אינה תואמת עם שרת ההעברה %2$s." + מחק עד 20 הודעות בבת אחת. + הודעות ישירות בין חברים חסומות. + השבת מחיקת הודעות + אל תשתמש בתעודות עם פרוקסי. + ירד + %d דיווחים + יורד %s (%s) + השבת מחיקת הודעות אוטומטית? + כפילויות + תנאי שימוש + מחק בלי להתריע + הורדה + הודעות ישירות בין חברים אסורות בצ\'אט זה. + אל תחמיץ הודעות חשובות. + הורד גרסאות חדשות מ GitHub. + פרופיל לא הולם + לפרטיות מטא דאטא טובה יותר. + אפשר Flux בהגדרות רשת ושרתים בשביל לשפר את הפרטיות של המטא דאטא + מעביר %1$s הודעות + שגיאה בקריאת משפט-סיסמה של מסד נתונים + משתמשים יכולים לדווח על הודעות לאחראי תוכן + שגיאת אתחול ב WebView, וודא שיש לך WebView מותקן והוא תותך בארכיטקטורה arm64\nשגיאה: %s + שגיאה בשמירת פרוקסי + לפרופיל צ\'אט %s: + למסלול פרטי + שגיאה בשמירת ההגדרות + שגיאה בהעברת ההודעות + שגיאה בהחלפת פרופיל + אפשר לוגים + איך זה משפר את הפרטיות + שיפור בגלילה בצ\'אט + תוכן לא הולם + שגיאה בקבלת תנאי שימוש + שגיאות בתצורת השרתים. + שרת ההעברות %1$s לא הצליח להתחבר לשרת היעד %2$s.נסה שוב במועד מאוחר יותר. + דיווחים מחברי הקבוצה + שרתי קבצים ומדיה + קישור לא תקין + שגיאה ביצירת רשימת צא\'טים חדשה + שגיאת עדכון רשימת צ\'אטים + שגיאה בטעינת רשימות הצא\'טים + ערוך + ודא שתצורת ה- proxy נכונה. + הזמן + זה מגן על כתובת ה- IP והחיבורים שלך. + עזוב צ\'אט + תקן + לתקן חיבור? + מחיקה מהירה יותר של קבוצות. + שליחת הודעות מהירה יותר. + העבר הודעות ללא קבצים? + הזמן לצ\'אט + שרת ההעברות: %1$s\nשגיאת שרת יעד: %2$s + שרת ההעברות: %1$s\nשגיאה: %2$s + למדיה חברתית + הגדל את גודל הגופן. + רשימה + מעודפים + קבוצות + שם הרשימה.. + שם הרשימה והאמוג\'י צריכים להיות שונים משאר הרשימות. + שמור שיחה + הזמן + ממשק בשפה הליטאית + הקובץ נחסם ע\"י מפעיל השרת:\n%1$s. + בשבילי + לכל האחראי תוכן + העבר הודעות… + שגיאה בשמירת המסד נתונים + שגיאה בהוספת שרת + שגיאת עדכון שם שרת + להעביר %1$s הודעה/ות? + שגיאה בשליחת הדיווח + שגיאה בשמירת שרתים + לעזוב את הצ\'אט? + העבר עד 20 הודעות בבת אחת. + קבל התראה כאשר מתייגים אותך + עזור לאחראי תוכן בדיווחים על תוכן בעייתי בקבוצות + הוסף רשימה + הדגשה נוספת 2 + בשימוש שלך ב- SimpleX Chat אתה מסכים ל:\n\n- לשלוח רק תוכן חוקי בקבוצות ציבוריות.\n\n- לכבד את שאר המשתמשים - לא לשלוח ספאם. + %s.]]> + %s.]]> + הוסף חברים + שרתי הודעות שנוספו + שרתי מדיה וקבצים שנוספו + הוסף לרשימה + חיבורים פעילים + הוסף את חברי הצוות שלך לשיחות. + האפליקציה תמיד רצה ברקע + הגדרות כתובת + סרגל הכלים של האפליקציה + סשן לאפליקציה + כתובת או קישור חד פעמי? + הוסף חברי צוות + %s.]]> + צ\'אט + צא\'טים עסקיים + "הזבל נמחק" + אנשי קשר + איש הקשר יימחק - לא ניתן לבטל זאת! + איש הקשר נמחק. + ספאם + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index ff6b4e456c..21c04b8473 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -111,8 +111,8 @@ チャット読み込みに失敗 チャット読み込みに失敗 アプリを更新し、開発者にご連絡ください。 - 即時通知! - 即時通知が無効になってます! + 通知の常時受信! + 通知の常時受信が無効になってます! パスフレーズが必要 プライベート 連絡先の名前 @@ -150,7 +150,7 @@ グループのプロフィールはサーバではなく、メンバーの端末に保存されます。 グループのプロフィールが更新されました。 連絡先とメッセージ内容をプライベートにする。 - 即時通知 + 通知の常時受信 SMPサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 WebRTC ICEサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 SimpleX の仕様 @@ -480,7 +480,7 @@ 新しい繋がりのリクエスト コピー メッセージを削除しますか? - 編集する + 編集 プライベートにする メッセージが削除されます (※元に戻せません※)! メッセージが削除対象となります。宛先にはメッセージの解読ができます。 @@ -529,8 +529,8 @@ 応答 分散型 スパム耐性 - 即時 - 定期的 + 常時受信 + 定期的に受信 通話は既に終了してます! エンドツーエンド暗号化済みの音声通話 無視 @@ -740,7 +740,7 @@ 接続中… 次世代のプライベートメッセンジャー ビデオ通話 - アプリが稼働中に + アプリがアクティブ時のみ WebRTC ICEサーバ あなたのICEサーバ 設定 @@ -1267,7 +1267,7 @@ KB あたりのプロトコル タイムアウト グループメンバーはファイルやメディアを送信できます。 アドレス変更の中止 - このグループでは、ファイルとメディアは禁止されています。 + ファイルとメディアは禁止されています。 終了しますか? アプリを再起動するまで通知は機能しません。 未読とお気に入りをフィルターします。 @@ -1961,4 +1961,51 @@ 設定 情報がありません、リロードしてください SMPサーバ - \ No newline at end of file + メッセージ + ウェブサイト + ビデオ + 設定画面からサーバを構成できます。 + 改定履歴を開く + 全てのチャットが %s から削除され、リスト自体も削除されます + アプリのツールバー + ログを有効化 + メッセージ受信を10分毎に確認します + バックグラウンドでアプリが常時動作します + SimpleX ChatとFluxは、Fluxが運営するサーバをアプリに組み込むことに合意しました。 + 利用条件をレビュー + プリセットサーバ + 利用条件を承諾 + 透過度 + %s の利用条件が受け入れられます。]]> + 新しいメッセージ + メッセージの形 + リストを保存 + 削除 + リスト名... + 編集 + リストを削除しますか? + 利用条件 + 続ける + リストを作成 + サーバオペレータ + 利用条件を開く + バックグラウンドサービスを使用しない + 利用条件の承諾 + %s の利用条件に承諾しています。]]> + 後で作成する場合はメニューから「SimpleXのアドレスを作成」を選択してください。 + 運営者について + 1年 + 1 件のレポート + モデレーターにメッセージを報告することを許可する + これらのメンバーからの新しいメッセージはすべて非表示になります! + リストに追加 + アドレス設定 + チームのメンバーを会話に追加する + 追加されたメッセージサーバー + アドレスか使い捨てのリンク? + 友達を追加 + チームメンバーを追加 + リストを追加 + すべて + ワンタイムリンクを生成 + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index bf07c10a6f..651d32518f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -281,7 +281,7 @@ %d 개의 파일 총 크기 %s 다이렉트 이 채팅에서는 사라지는 메시지를 사용할 수 없습니다. - 이 그룹에서는 사라지는 메시지를 사용할 수 없습니다. + 사라지는 메시지를 사용할 수 없습니다. %d분 %d 개월 %d 분 @@ -966,7 +966,7 @@ 활성 연결 모든 프로필 적용 - 모든 메시지가 삭제됩니다. 이 결정은 취소할 수 없습니다! + 모든 메시지가 삭제됩니다. 이 결정은 되돌릴 수 없습니다! 파일 및 미디어 전송을 허용합니다. 새로운 무작위 프로필이 공유됩니다. 모든 색상 모드 @@ -1444,10 +1444,10 @@ %s.]]> %s 이 현재 사용 중]]> %s.]]> - SimpleX 백그라운드 서비스를 제공합니다. - 이 기능은 하루에 몇 퍼센트의 배터리를 소모합니다.]]> + 개인 정보를 보호하기 위해 SimpleX는 푸시 알림을 사용하는 대신 백그라운드에서 실행됩니다.]]> %s 의 서버를 사용하려면 사용 약관에 동의하십시오.]]> %1$s 에 연결 중입니다.]]> - SimpleX의 백그라운드에서 실행되도록 허용하십시오. 그렇지 않으면 알림을 사용할 수 없습니다.]]> + 허용을 선택하면 알림을 즉시 받을 수 있습니다.]]> 앱 설정에서 앱 배터리 사용량 / 제한 없음 을 선택하십시오.]]> %s 이 현재 비활성화됨]]> SimpleX Chat 개발자에게 연결하여 질문하고 업데이트를 받을 수 있습니다.]]> @@ -1457,4 +1457,69 @@ %1$s 그룹에 가입하는 중 입니다.]]> %s 버전이 지원되지 않습니다. 두 기기에서 동일한 버전을 사용하는지 확인하십시오.]]> %1$s 그룹에 속해 있습니다.]]> - \ No newline at end of file + 약관을 수락하는 중 오류 발생 + 연결 보안 + 로그 활성화 + 비즈니스 주소 + 일회용 링크 생성 + 10분마다 메시지 확인 + %s:에서 활성화된 운영자에 대한 약관이 자동으로 수락됩니다. + 현재 약관 텍스트를 로드할 수 없습니다, 다음 링크를 통해 약관을 검토할 수 있습니다: + 사용 약관 + Flux 활성화 + 종단 간 암호화로 전송됩니다.]]> + 앱이 항상 백그라운드에서 실행 + 활성화된 운영자에 대한 약관은 30일 후에 수락됩니다. + 팀 멤버 추가하기 + 친구 추가 + 모든 멤버에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! + 채팅 삭제 + 채팅을 삭제하시겠습니까? + 채팅 + 서버 추가 중 오류 + 멤버 간의 다이렉트 메시지는 금지됩니다. + Xiaomi 기기: 알림이 작동하려면 시스템 설정에서 자동 시작을 사용하도록 설정하세요.]]> + 이 채팅에서는 멤버 간의 다이렉트 메시지가 금지됩니다. + 당신에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! + %s:에서 약관이 수락됩니다. + %1$s에 연결되어 있습니다.]]> + 채팅이 이미 존재합니다! + 계속 + 수락된 초대 + 비즈니스 채팅 + 운영자 소개 + 연결에 암호화 재협상이 필요합니다. + 암호화 재협상이 진행 중입니다. + 팀 멤버를 대화에 추가하세요. + 다른 이유 + 보관 + 1 보고서 + 리스트 추가 + 전부 + 목록 %s의 모든 차트가 제거되었고, 목록도 삭제되었습니다. + 1 년 + 리스트에 추가하기 + 보관된 보고서 + 보관된 신고: %s + 커뮤니티 가이드라인 위반 + 신고를 보관하시겠습니까? + 신고 보관 + 연락처 + 연결이 차단되었습니다 + 연결이 서버 운영자에 의해 차단되었습니다:\n%1$s + 비즈니스 + 순서 변경 + 문의 + 연결이 준비되지 않았습니다. + 자동 메시지 삭제를 변경하시겠습니까? + 그룹 성능 향상 + 모든 신고가 보관됩니다. + 모든 신고를 보관하시겠습니까? + 신고 보관 + 목록 변경 + 메시지를 운영자에게 신고할 수 있도록 허용합니다. + 더 나은 개인정보 보호 및 보안 + 신고 %d건을 보관하시겠습니까? + 이 표시 이름은 유효하지 않습니다. 다른 이름을 선택하세요. + 데스크톱에 연결하는 동안 시간 초과되었습니다 + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index fbad2dc4e9..adf66650f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1774,4 +1774,4 @@ %1$d failas (-ai, -ų) vis dar atsisiunčiamas (-i, -a). Nepavyko atsisiųsti %1$d failo (-ų). %d pasirinkta - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index ced3b9a3b0..d07cb6db39 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -445,7 +445,7 @@ Berichten Nieuw wachtwoord… Keychain fout - Word lid van + Word lid Groep verlaten\? Nieuwe leden rol Geen contacten om toe te voegen @@ -694,7 +694,7 @@ Server test Beoordeel de app Gebruik server - Gebruik van SimpleX Chat servers. + Gebruik SimpleX Chat servers. Uw server adres Uw server Transport isolation @@ -854,7 +854,7 @@ Uw contacten kunnen volledige verwijdering van berichten toestaan. U moet elke keer dat de app start het wachtwoord invoeren, deze wordt niet op het apparaat opgeslagen. Verkeerd wachtwoord voor de database - Bewaar het wachtwoord en open je chats + Wachtwoord opslaan en open je chats De poging om het wachtwoord van de database te wijzigen is niet voltooid. Database back-up terugzetten Database back-up terugzetten\? @@ -1047,7 +1047,7 @@ Je kunt SimpleX Vergrendeling aanzetten via Instellingen. Vergrendeling inschakelen Vergrendeling modus - Indienen + Bevestigen Verificatie geannuleerd Wijzig de vergrendelings modus Bevestig toegangscode @@ -1059,7 +1059,7 @@ Toegangscode ingesteld! Systeem Decodering fout - De hash van het vorige bericht is anders. + De hash van het vorige bericht is anders.\" %1$d berichten overgeslagen. Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt. Meld het alsjeblieft aan de ontwikkelaars. @@ -1448,7 +1448,7 @@ Gekoppelde mobiele apparaten Desktop Verbonden met desktop - Deze apparaatnaam + Naam van dit apparaat Het bestand laden Verbinding maken met desktop Desktop apparaten @@ -1596,14 +1596,14 @@ contactadres verwijderd profielfoto verwijderd nieuw contactadres instellen - nieuwe profielfoto instellen + nieuwe profielfoto Geblokkeerd door beheerder Blokkeren voor iedereen Lid voor iedereen blokkeren? Deblokkeer voor iedereen geblokkeerd geblokkeerd door beheerder - %s geblokkeerd + blokkeerde %s %d berichten geblokkeerd door beheerder %s gedeblokkeerd Fout bij blokkeren van lid voor iedereen @@ -1832,7 +1832,7 @@ Systeem Wallpaper achtergrond Stel het standaard thema in - Toon chatlijst in nieuw venster + Toon chat-lijst in nieuw venster geen Foutopsporing bezorging Informatie over berichtenwachtrij @@ -1872,8 +1872,7 @@ Details Berichten ontvangen Bericht ontvangst - Beginnend vanaf %s. -\nAlle gegevens zijn privé op uw apparaat. + Vanaf %s.\nAlle gegevens blijven privé op uw apparaat. Verbonden servers In behandeling Eerder verbonden servers @@ -2040,11 +2039,11 @@ Verwijder maximaal 20 berichten tegelijk. Sommige bestanden zijn niet geëxporteerd Alle hints resetten - Chatlijst wisselen: + Chat-lijst wisselen: U kunt dit wijzigen in de instellingen onder uiterlijk Creëren Vervagen voor betere privacy. - Afspelen via de chatlijst. + Afspelen via de chat-lijst. Download nieuwe versies van GitHub. Vergroot het lettertype. App automatisch upgraden @@ -2184,7 +2183,7 @@ Netwerk decentralisatie De tweede vooraf ingestelde operator in de app! Als uw contactpersoon bijvoorbeeld berichten ontvangt via een SimpleX Chat-server, worden deze door uw app via een Flux-server verzonden. - Flux inschakelen + Schakel Flux in bij Netwerk- en serverinstellingen voor betere privacy van metagegevens. Geen bericht App-werkbalken Vervagen @@ -2244,7 +2243,145 @@ Verklein de berichtgrootte of verwijder de media en verzend het bericht opnieuw. geaccepteerde uitnodiging Wanneer er meer dan één operator is ingeschakeld, beschikt geen enkele operator over metagegevens om te achterhalen wie met wie communiceert. - gevraagd om verbinding te maken + verzocht om verbinding te maken Over operatoren Simplex-chat en flux hebben een overeenkomst gemaakt om door flux geëxploiteerde servers in de app op te nemen. - \ No newline at end of file + Logs inschakelen + Fout bij het opslaan van de database + Verbinding vereist heronderhandeling over encryptie. + Er wordt opnieuw onderhandeld over de encryptie. + Verbinding herstellen? + Herstel + Verbinding nog niet klaar + Fout bij het bijwerken van de chat-lijst + Geen chats in lijst %s. + Favorieten + Groepen + Geen chats + Geen chats gevonden + Lijst toevoegen + alle + Openen met %s + Maak een lijst + Verwijderen + Lijst verwijderen? + Bewerk + Naam van lijst... + De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn. + Lijst opslaan + Lijst + Fout bij het laden van chat-lijsten + Alle chats worden verwijderd uit de lijst %s, en de lijst wordt verwijderd + Toevoegen aan lijst + bedrijven + Contacten + Fout bij het aanmaken van chat-lijst + Geen ongelezen chats + Notities + Lijst wijzigen + Wijzig volgorde + ‐Fout bij het opslaan van instellingen + Fout bij het rapporteren + Archief + Schending van de communityrichtlijnen + Een andere reden + Rapporteer ledenprofiel: alleen groepsmoderators kunnen dit zien. + moderator + Inhoud melden: alleen groepsmoderators kunnen dit zien. + gearchiveerd rapport + Ongepaste inhoud + Ongepast profiel + Alleen de verzender en moderators zien het + Alleen jij en moderators zien het + Spam + Rapport archiveren? + rapporteren + Reden melding? + Het rapport wordt voor u gearchiveerd. + Anders melden: alleen groepsmoderators kunnen het zien. + Spam melden: alleen groepsmoderators kunnen het zien. + Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. + Rapport archiveren + Rapport verwijderen + gearchiveerd rapport door %s + 1 rapport + %d rapporten + Ledenrapporten + Inhoud schendt de gebruiksvoorwaarden + Verbinding geblokkeerd + Verbinding is geblokkeerd door serveroperator:\n%1$s. + Bestand is geblokkeerd door server operator:\n%1$s. + Nee + Link openen + Open links vIn de chat-lijst + Vragen + Rapporten + Spam + Weblink openen? + Ja + Stel chatnaam in + Automatisch verwijderen van berichten wijzigen? + 1 jaar + Berichten in deze chat zullen nooit worden verwijderd. + Deze actie kan niet ongedaan worden gemaakt. De berichten die eerder in deze chat zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. + Automatisch verwijderen van berichten uitschakelen? + Verwijder chatberichten van uw apparaat. + Berichten verwijderen uitschakelen + standaard (%s) + TCP-poort voor berichtenuitwisseling + Gebruik een webpoort + Gebruik TCP-poort %1$s wanneer er geen poort is opgegeven. + Alles dempen + Ongelezen vermeldingen + Je kunt maximaal %1$s leden per bericht vermelden! + Hiermee kunt u berichten rapporteren aan moderators. + Alle rapporten worden voor u gearchiveerd. + Alle rapporten archiveren? + %d rapporten archiveren? + Voor mij + Leden kunnen berichten melden bij moderators. + Het melden van berichten in deze groep is niet toegestaan + Rapporteer: %s + Het melden van berichten aan moderators is niet toegestaan. + Voor alle moderators + Rapporten archiveren + Betere prestaties van groepen + Stel de berichtvervaldatum in chats in. + Betere privacy en veiligheid + Mis geen belangrijke berichten. + Sneller verwijderen van groepen. + Ontvang een melding als u vermeld wordt. + Help beheerders bij het modereren van hun groepen. + Vermeld leden 👋 + Organiseer chats in lijsten + Namen van persoonlijke mediabestanden. + Rapporteer privé + Sneller verzenden van berichten. + afgewezen + afgewezen + Fout bij het lezen van database wachtwoord + Alle nieuwe berichten van deze leden worden verborgen! + Leden voor iedereen blokkeren? + Leden worden uit de chat verwijderd. Dit kan niet ongedaan worden gemaakt! + Leden worden uit de groep verwijderd. Dit kan niet ongedaan worden gemaakt! + Leden voor iedereen deblokkeren? + Berichten van deze leden worden getoond! + moderatoren + Wachtwoord in Keystore kan niet worden gelezen, voer deze handmatig in. Dit kan zijn gebeurd na een systeemupdate die niet compatibel is met de app. Als dit niet het geval is, neem dan contact op met de ontwikkelaars. + in afwachting van goedkeuring + Leden verwijderen? + Bijgewerkte voorwaarden + Wachtwoord in Keystore kan niet worden gelezen. Dit kan zijn gebeurd na een systeemupdate die niet compatibel is met de app. Als dit niet het geval is, neem dan contact op met de ontwikkelaars. + in behandeling + Accepteer + Door SimpleX Chat te gebruiken, gaat u ermee akkoord:\n- alleen legale content te versturen in openbare groepen.\n- andere gebruikers te respecteren – geen spam. + SimpleX channel link + Voor deze link is een nieuwere app-versie vereist. Werk de app bij of vraag je contactpersoon om een compatibele link te sturen. + Volledige link + Niet-ondersteunde verbindingslink + Gebruik korte links (BETA) + Korte link + Serveroperators configureren + Privacybeleid en gebruiksvoorwaarden. + Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a748bf2741..5102c98563 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -277,7 +277,7 @@ Zeskanuj kod bezpieczeństwa z aplikacji Twojego kontaktu. Kod bezpieczeństwa Ustawienia - Udostępnij 1-razowy link + Udostępnij link jednorazowy Pokaż kod QR %s jest zweryfikowany Ten kod QR nie jest linkiem! @@ -418,7 +418,7 @@ odrzucone połączenie sekret uruchamianie… - strajk + przekreślenie Brak identyfikatorów użytkownika. Następna generacja \nprywatnych wiadomości oczekiwanie na odpowiedź… @@ -1129,9 +1129,9 @@ Podgląd Otwieranie bazy danych… Błąd ustawiania adresu - Otwórz profile czatu + Zmień profile czatu O adresie SimpleX - 1-razowy link + link jednorazowy Podręczniku Użytkownika.]]> Adres SimpleX Kiedy ludzie proszą o połączenie, możesz je zaakceptować lub odrzucić. @@ -1373,9 +1373,7 @@ Arabski, bułgarski, fiński, hebrajski, tajski i ukraiński - dzięki użytkownikom i Weblate. Utwórz nowy profil w aplikacji desktopowej. 💻 Przełącz incognito przy połączeniu. - - połącz się z usługą katalogową (BETA)! -\n- potwierdzenia dostaw (do 20 członków). -\n- szybszy i stabilniejszy. + - połącz się z usługą katalogową (BETA)!\n- potwierdzenia dostaw (do 20 członków).\n- szybszy i stabilniejszy. Otwórz Błąd tworzenia kontaktu członka Wyślij wiadomość bezpośrednią aby połączyć @@ -1499,15 +1497,15 @@ Rozłączyć komputer? Proszę poczekać na załadowanie pliku z połączonego telefonu Zweryfikuj połączenie - Odświerz + Odśwież Możesz ustawić go jako widoczny dla swoich kontaktów SimpleX w Ustawieniach. Losowy błąd wyświetlania zawartości błąd wyświetlania wiadomości - Aby umożliwić aplikacji mobilnej łączenie się z komputerem, otwórz ten port w zaporze sieciowej, jeśli jest ona włączona + Aby umożliwić aplikacji mobilnej połączenie z komputerem stacjonarnym, otwórz ten port w swojej zaporze sieciowej, jeśli jest włączona. Utwórz profil czatu Widok uległ awarii - Otwórz port w zaporze + Otwórz port w zaporze sieciowej Rozłącz telefony Brak połączonych telefonów Historia nie jest wysyłana do nowych członków. @@ -1523,7 +1521,7 @@ Włącz dostęp do kamery Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia. Zachować nieużyte zaproszenie? - Udostępnij ten jednorazowy link + Udostępnij ten jednorazowy link zaproszenia Utwórz grupę: aby utworzyć nową grupę.]]> Widoczna historia Pin aplikacji @@ -1851,8 +1849,7 @@ Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze. Błąd pliku Błąd serwera plików: %1$s - Sprawdź, czy telefon i komputer są podłączone do tej samej sieci lokalnej i czy zapora sieciowa komputera umożliwia połączenie. -\nProszę podzielić się innymi problemami z deweloperami. + Sprawdź, czy telefon i komputer są podłączone do tej samej sieci lokalnej i czy zapora sieciowa komputera umożliwia połączenie.\nProszę zgłoś wszystkie inne problemy deweloperom. Tymczasowy błąd pliku Zły klucz lub nieznany adres fragmentu pliku - najprawdopodobniej plik został usunięty. Nie można wysłać wiadomości @@ -1932,8 +1929,7 @@ Wyświetlanie informacji dla Statystyki Sesje transportowe - Zaczynanie od %s. -\nWszystkie dane są prywatne na Twoim urządzeniu. + Zaczynanie od %s. \nWszystkie dane są prywatne na Twoim urządzeniu. Połącz ponownie wszystkie serwery Połączyć ponownie serwer? Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -2114,4 +2110,78 @@ Usuń lub moderuj do 200 wiadomości. Przekazywanie do 20 wiadomości jednocześnie. Przełącz profil czatu dla zaproszeń jednorazowych. - \ No newline at end of file + Utwórz link jednorazowy + Udostępnij jednorazowy link znajomemu + Adres SimpleX czy link jednorazowy? + Adres lub link jednorazowy? + tylko z jednym kontaktem - udostępnij go osobiście lub przez dowolny komunikator.]]> + Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator. + raport zarchiwizowany przez %s + Inny powód + Zarchiwizuj raport + Ustawienia adresowe + O operatorach + Adres służbowy + Zaakceptowane warunki + %s.]]> + Dodane serwery wiadomości + %s.]]> + Zarchiwizować raport? + Archiwum + Wszystkie + Biznesy + raport + Zapytaj + %s.]]> + %s.]]> + Rozmyj + Dodaj członków drużyny do konwersacji + Dodaj członków drużyny + Konwersacje służbowe + Dodaj listę + Dodaj do listy + "Wszystkie konwersacje zostaną usunięte z list %s, oraz listy." + zaszyfrowanej, z post-kwantowym bezpieczeństwem w bezpośrednich wiadomościach.]]> + Dodaj znajomych + Dodane serwery plików i mediów + Paski narzędziowe aplikacji + Aplikacja działa zawsze w tle + %s.]]> + %s.]]> + %s.]]> + Zaakceptuj warunki + zaproszenie zaakceptowane + zarchiwizowany raport + Urządzenia Xiaomi: Włącz Autostart w ustawieniach systemowych, aby powiadomienia działały.]]> + Usuń raport + Kontakty + Sprawdzaj wiadomości co 10 minut + Usuń czat + Naruszenie wytycznych społeczności + Warunki zaakceptowane na: %s. + Warunki zostaną automatycznie zaakceptowane dla włączonych operatorów na: %s. + %s.]]> + %s, Zaakceptuj warunki użytkowania.]]> + Warunki użytkowania + Treść narusza warunki użytkowania + Połączenie zablokowane + Połączenie jest zablokowane przez operatora serwera:\n%1$s. + Lista zmian + Utwórz listę + Usuń + Nie można załadować tekstu aktualnych warunków, możesz przejrzeć warunki za pomocą tego linku: + Kontynuuj + Czat + Czat już istnieje! + %1$s.]]> + Usunąć listę? + Połączenie nie jest gotowe. + Usunąć czat? + Czat zostanie dla Ciebie usunięty - nie można tego cofnąć! + Czat zostanie usunięty dla wszystkich członków - nie można tego cofnąć! + Warunki będą akceptowane w dniu: %s. + Bezpieczeństwo połączenia + Warunki będą akceptowane dla operatorów włączonych po 30 dniach. + Zmień kolejność + Połączenie wymaga renegocjacji szyfrowania. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 3b139013fc..a0cce488af 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -323,7 +323,7 @@ A autenticação do dispositivo não está habilitada. Você pode ativar o Bloqueio SimpleX em Configurações, depois de ativar a autenticação do dispositivo. Desativar Desconectado - Mensagens que temporárias são proibidas neste grupo. + Mensagens temporárias são proibidas. Erro ao salvar arquivo O nome de exibição não pode conter espaços em branco. chamada de áudio criptografada ponta-a-ponta @@ -341,7 +341,7 @@ Ocultar Da Galeria Vídeo - Os membros do grupo podem enviar mensagens temporárias. + Os membros podem enviar mensagens temporárias. Arquivo Nome completo: Chamada de áudio recebida @@ -360,7 +360,7 @@ Mensagens que desaparecem Preferências do grupo Mensagens temporárias são proibidas nesse bate-papo. - Os membros do grupo podem enviar mensagens diretas. + Os membros podem enviar mensagens diretas. %dmês Link completo Ocultar @@ -377,7 +377,7 @@ Falha ao carregar as conversas Arquivo: %s Arquivo salvo - Os membros do grupo podem enviar mensagens de voz. + Os membros podem enviar mensagens de voz. O grupo será excluído para todos os membros - isso não pode ser desfeito! AJUDA Ocultar contato e mensagem @@ -391,7 +391,7 @@ Imagem salva na Galeria anônimo via link do grupo Chamada de vídeo recebida - permite que o SimpleX funcione em segundo plano na próxima caixa de diálogo. Caso contrário, as notificações serão desabilitadas.]]> + Permita na próxima caixa de diálogo para receber notificações instantaneamente]]> Gerar um link de convite de uso único Arquivo não encontrado Se você optar por rejeitar o remetente NÃO será notificado. @@ -406,7 +406,7 @@ perfil do grupo atualizado Grupo excluído O modo Incognito protege sua privacidade usando um novo perfil aleatório para cada contato. - Os membros do grupo podem excluir mensagens enviadas de forma irreversível. (24 horas) + Os membros grupo podem excluir mensagens enviadas de forma irreversível. (24 horas) %dsemana Configuração de servidor aprimorada Interface francesa @@ -534,7 +534,7 @@ Suas preferências Definir preferências de grupo Somente você pode excluir irreversivelmente as mensagens (seu contato pode marcá-las para exclusão). (24 horas) - A exclusão irreversível de mensagens é proibida neste grupo. + A exclusão irreversível de mensagens é proibida. Os destinatários vêem as atualizações conforme você as digita. Uso da bateria reduzido Mais melhorias chegarão em breve! @@ -552,7 +552,7 @@ O remetente pode ter excluído a solicitação de conexão. Notificações periódicas estão desativadas! As notificações instantâneas estão desativadas! - serviço em segundo plano SimpleX - ele usa uma porcentagem da bateria por dia.]]> + SimpleX executa em segundo plano em vez de usar notificações push.]]> Executa quando o aplicativo está aberto enviado o envio falhou @@ -587,7 +587,7 @@ Servidores SMP Endereço do servidor pré-definido Rejeitar - %1$d mensagem(s) ignorada(s) + %1$d mensagem(ens) ignorada(s) Proteger a tela do aplicativo Enviar prévias de links Privacidade e segurança @@ -641,7 +641,7 @@ \nAtenção: você não será capaz de se conectar aos servidores sem um endereço .onion Versão principal: v%s repositório do GitHub.]]> - Pode ser mudado mais tarde via configurações. + Como isso afeta a bateria %1$s quer se conectar com você via sem criptografia ponta-a-ponta Abrir @@ -849,7 +849,7 @@ Para começar um novo bate-papo Ligar Bem-vindo(a)! - A próxima geração \nde mensageiros privados + O futuro da transmissão de mensagens PROXY SOCKS A tentativa de alterar a senha do banco de dados não foi concluída. Pare o bate-papo para exportar, importar ou excluir o banco de dados do chat. Você não poderá receber e enviar mensagens enquanto o chat estiver interrompido. @@ -862,7 +862,7 @@ A atualização das configurações reconectará o cliente a todos os servidores. Atualizar Sistema - Mensagens de voz são proibidas neste grupo. + Mensagens de voz são proibidas. Verificar a segurança da conexão Para proteger o fuso horário, os arquivos de imagem/voz usam UTC. formato de mensagem desconhecido @@ -1061,8 +1061,8 @@ Senha alterada! Você pode ativar o bloqueio SimpleX via configurações. Hash de mensagem incorreta - O hash da mensagem anterior é diferente. - %1$d descriptografia das mensagens falhou + O hash da mensagem anterior é diferente.\" + %1$d mensagens falharam em serem descriptografadas. ID de mensagem incorreta A ID da próxima mensagem está incorreta (menor ou igual à anterior). \nIsso pode acontecer por causa de algum bug ou quando a conexão está comprometida. @@ -1149,7 +1149,7 @@ Digite a mensagem de boas-vindas... (opcional) Salvar configurações de aceitação automática Abrindo banco de dados… - Abrir perfis de bate-papo + Alterar perfis de conversa Compartilhar endereço com os contatos\? Seus contatos continuarão conectados. Todos os dados do aplicativo serão excluídos. @@ -1171,7 +1171,7 @@ Permitir reações à mensagens. Somente você pode adicionar reações à mensagens. Somente seu contato pode adicionar reações à mensagens. - Reações à mensagens são proibidas neste grupo. + Reações a mensagens são proibidas. horas minutos segundos @@ -1199,7 +1199,7 @@ %s (atual) Permitir que seus contatos adicionem reações à mensagens. Você e seu contato podem adicionar reações à mensagens. - Os membros do grupo podem adicionar reações às mensagens. + Os membros podem adicionar reações. Reações à mensagens são proibidas neste bate-papo. Proibir reações à mensagens. personalizado @@ -1249,7 +1249,7 @@ código de segurança alterado Renegociar criptografia %s em %s - Arquivos e mídia são proibidos neste grupo. + Arquivos e mídias são proibidos. Proibir o envio de arquivos e mídia. criptografia OK para %s Correção não suportada pelo membro do grupo @@ -1265,7 +1265,7 @@ Erro ao sincronizar conexão Favorito Arquivos e mídia proibidos! - Os membros do grupo podem enviar arquivos e mídia. + Os membros podem enviar arquivos e mídias. Corrigir Correção não suportada pelo contato Desligar @@ -1800,7 +1800,7 @@ Aviso: iniciar conversa em múltiplos dispositivos não é suportado e pode causar falhas na entrega de mensagens Internet cabeada não deve usar a mesma base de dados em dois dispositivos.]]> - Membros do grupo podem enviar link SimpleX + Membros podem enviar links SimpleX. Importando arquivo Modo claro Ativado para @@ -1897,7 +1897,7 @@ Escala Preencher Ajustar - Links SimpleX são proibidos neste grupo. + Links SimpleX são proibidos. Migrar para outro dispositivo via QR code. Chamadas picture-in-picture Use o aplicativo enquanto está em chamada. @@ -1982,8 +1982,7 @@ Sessões de transporte Recepção de mensagem Pendente - Começando de %s. -\nTodos os dados são privados do seu dispositivo. + Começando em %s.\nTodos os dados são mantidos privados em seu dispositivo. Total Servidores proxiados Servidores conectados anteriormente @@ -2056,7 +2055,7 @@ Por favor reinicie o aplicativo. Me lembre mais tarde Para ser notificado sobre os novos lançamentos, habilite a checagem periódica de versões Estáveis e Beta. - Barra de ferramentas de conversa acessível + Barras de ferramentas de aplicativos acessível Falha no baixar de %1$d arquivo(s). %1$s mensagens não encaminhadas. DADOS DO BATE-PAPO @@ -2066,15 +2065,15 @@ Sua conexão foi movida para %s, mas um erro inesperado ocorreu ao redirecioná-lo para o seu perfil. %1$d erro(s) de arquivo(s): \n%2$s - %1$d outro erro de arquivo. + %1$d outro(s) erro(s) de arquivo(s). Erro ao encaminhar mensagens. Encaminhar %1$s mensagens? Encaminhar mensagens sem arquivos? As mensagens foram excluidas após vocês selecioná-las. Nada para encaminhar! - %1$d arquivo(s) ainda estão sendo baixados. - %1$d arquivos foram excluidos. - %1$d arquivos não foram baixados. + %1$d o(s) arquivo(s) ainda está(ão) sendo baixado(s). + %1$d arquivo(s) foi(ram) excluído(s). + %1$d arquivo(s) não foi(ram) baixado(s). Baixar Emcaminhar mensagens… Encaminhando %1$s mensagens. @@ -2094,4 +2093,293 @@ Senha Nome de usuário Sessão do aplicativo - \ No newline at end of file + Endereço ou link de uso único? + Configurações de endereço + Adicione membros da sua equipe às conversas. + Melhores ligações + Servidores de mensagem adicionados + Adicionado servidores de mídia e arquivos + Barra de ferramentas + Aplicativo sempre roda em segundo plano + Adicionar amigos + Condições aceitas + Convite aceito + Adicionar membros da equipe + Sobre operadores + Aceite as condições + denúncia arquivada por %s + Outra razão + Adicionar lista + Todas as conversas serão removidas da lista %s, e a lista será apagada + Melhor segurança ✅ + Arquivar denúncia? + Arquivar denúncia + Todos + Adicionar à lista + Em dispositivos Xiaomi: por favor, ative a opção Autostart nas configurações do sistema para que as notificações funcionem.]]> + Datas de mensagens melhores. + Melhor experiência do usuário + %1$s.]]> + Arquivar + Perguntar + Desfoque + Endereço comercial + denúncia arquivada + Deletar chat + O texto das condições atuais não pôde ser carregado, você pode revisar as condições por meio deste link: + %s.]]> + Formato de mensagem personalizável. + Envio de mensagens mais rápido. + Checar mensagens a cada 10 minutos + Todas novas mensagens destes membros serão ocultadas + Erro ao atualizar servidor + Permitir denunciar mensagens aos moderadores. + Melhorias de privacidade e segurança + Não perca mensagens importantes. + Chat já existente! + Ativar logs + Bloquear membros para todos? + Deletar ou moderar até 200 mensagens. + %s.]]> + %s.]]> + Mensagens diretas entre membros são proibidas neste chat. + Melhor desempenho de grupos + com criptografia de ponta-a-ponta, e com segurança pós-quântica em mensagens diretas.]]> + Chat será deletado para você - essa ação não pode ser desfeita! + Condições aceitas em: %s. + Mensagens diretas entre membros são proibidas. + %s.]]> + Alterar ordem + Erro ao ler a senha do banco de dados + Erro ao aceitar condições + Violação das diretrizes da comunidade + Erro ao salvar servidores + %d denúncias + 1 denúncia + com apenas um contato - compartilhe pessoalmente ou por qualquer aplicativo de mensagens.]]> + Erro ao salvar configurações + Criar link único + Reparar + %s, aceite as condições de uso.]]> + O conteúdo viola as condições de uso + Conexão bloqueada + A conexão está bloqueada pelo operador do servidor:\n%1$s. + O arquivo está bloqueado pelo operador do servidor:\n%1$s. + Deletar denúncia + Empresas + Alterar lista + Continuar + Erro ao salvar banco de dados + %s.]]> + Erro ao inicializar o WebView. Certifique-se de que você tenha o WebView instalado e que sua arquitetura suportada seja arm64.\nErro: %s + Alterar exclusão automática de mensagens? + Desativar exclusão automática de mensagens? + Deletar lista? + 1 ano + padrão (%s) + %s.]]> + %s.]]> + As condições serão aceitas automaticamente para operadores habilitados em: %s. + Segurança de conexão + As condições serão aceitas em: %s. + %s.]]> + Condições de uso + Erro ao adicionar servidor + Chats de empresas + para melhoria da privacidade de metadados. + Exclusão mais rápida de grupos. + Erro ao criar lista de chat + Erro ao carregar lista de chats + Erro ao atualizar a lista de chats + Contatos + Favoritos + Criar lista + Editar + Canto + Ativar o Flux nas Configurações de rede e servidores para melhor privacidade de metadados. + Todas denúncias serão arquivadas para você. + Arquivar todas denúncias? + Arquivar %d denúncias? + Arquivar denúncias + Para todos moderadores + Para mim + Deletar mensagens de chat do seu dispositivo. + Excluir chat? + O chat será deletado para todos os membros - essa ação não pode ser desfeita! + Desativar exclusão de mensagens + Renegociação de criptografia em andamento. + Deletar + Clique no botão de informação perto do campo de endereço para permitir usar o microfone. + As condições serão aceitas para operadores habilitados após 30 dias. + Por exemplo, se o seu contato receber mensagens por meio de um servidor SimpleX Chat, seu aplicativo as entregará por meio de um servidor Flux. + A conexão não está pronta. + Erros nas configurações de servidores. + Para o perfil de chat %s: + A conexão requer renegociação de criptografia. + Reparar conexão? + Erro ao criar denúncia + Chat + Seus servidores + aprovação pendente + pendente + Os membros podem denunciar mensagens aos moderadores. + Operadores da rede + Operador + Nenhum serviço de segundo plano + Abrir mudanças + moderadores + Descentralização da rede + Privacidade para seus clientes. + Mencione membros 👋 + Seja notificado quando mencionado. + Ajude os administradores a moderar seus grupos. + Organize os chats em listas + Nomes de arquivos de mídia privados. + Conteúdo inapropriado + Perfil inapropriado + Nenhuma mensagem de servidores. + Nenhuma mensagem + Denúncias de membros + Ou compartilhe em particular + Nenhum chat não lido + Nenhum chat + Notas + Abrir com %s + O nome da lista e o emoji devem ser diferentes para todas as listas. + Novas credenciais SOCKS serão usadas para cada servidor. + Notificações e bateria + As mensagens desses membros serão exibidas! + Silenciar tudo + Para redes sociais + Servidores predefinidos + Abrir links da lista de bate-papo + Abrir web link? + Convidar ao chat + Abrir condições + Nome da lista... + Novas credenciais SOCKS serão usadas toda vez que você iniciar o aplicativo. + Abrir link + Forma da mensagem + moderador + A mensagem é muito grande! + Por favor, reduza o tamanho da mensagem e a envie novamente. + Operador da rede + Para roteamento privado + Aprimorada a navegação de bate-papo + - Abra o chat na primeira mensagem não lida.\n- Pule para mensagens citadas. + Os membros serão removidos do chat. Essa ação não pode ser desfeita! + Said do chat + Os membros serão removidos do grupo. Essa ação não pode ser desfeita! + Nove servidor + Nenhum chat encontrado + As mensagens neste chat nunca serão excluídas. + A frase-senha na Keystore não pôde ser lida. Isso pode ter acontecido após uma atualização do sistema incompatível com o aplicativo. Se não for o caso, entre em contato com os desenvolvedores. + Somente os proprietários do chat podem alterar as preferências. + A frase-senha na Keystore não pôde ser lida, insira-a manualmente. Isso pode ter acontecido após uma atualização do sistema incompatível com o aplicativo. Se não for o caso, entre em contato com os desenvolvedores. + Grupos + Ou importar arquivo compactado + Lista + Reduza o tamanho da mensagem ou remova a mídia e envie novamente. + Como isso ajuda na privacidade + Não + Sair do chat? + O membro será removido do chat - essa ação não pode ser desfeita! + Encaminhe até 20 mensagens de uma vez. + Nenhuma mídia & nenhum arquivo de servidores. + Nenhum servidor para enviar arquivos. + Nenhum servidor para roteamento de mensagens privadas. + Nenhum servidor para receber arquivos. + Nenhum servidor para receber mensagens. + Abra Configurações do Safari / Websites / Microfone, e escolha Permitir para localhost. + Servidor do operador + Nenhum chat na lista %s. + Somente o remetente e os moderadores podem vê-lo. + Somente você e os moderadores podem ver isso + rejeitado + Denunciar + Proibir a denúncia de mensagens aos moderadores. + Denunciar conteúdo: somente os moderadores do grupo poderão ver. + Denunciar perfil de membro: somente moderadores do grupo poderão ver. + Dispositivos móveis remotos + Remover membros? + Denunciar outro: somente os moderadores do grupo poderão ver. + Qual é a razão da denúncia? + Denúncia: %s + rejeitado + É proibido denunciar mensagens neste grupo. + Barra de ferramentas de chat acessível + Denúncias + Operador do servidor alterado. + Definir nome do chat… + Compartilhar o endereço publicamente + Denunciar violação: somente os moderadores do grupo poderão ver. + %s servidores + Protocolos SimpleX analisados pela Trail of Bits. + Enviar denúncias privadas + Defina a expiração de mensagens em chats. + Spam + Spam + Salvar lista + Compartilhe um link único com um amigo + Endereço SimpleX ou link único? + Som silenciado + Alterne entre áudio e vídeo durante a chamada. + Selecione as operadoras de rede a serem utilizadas. + Revisar condições + Endereços SimpleX e links únicos são seguros para compartilhar por meio de qualquer mensageiro. + Operadores do servidor + Servidor adicionado ao operador %s. + riscar + Denunciar spam: somente os moderadores do grupo poderão ver. + Protocolo do servidor alterado. + Compartilhe o endereço do SimpleX nas redes sociais. + Revisar depois + Servidor + O SimpleX Chat e o Flux fizeram um acordo para incluir servidores operados pelo Flux no aplicativo. + conexão solicitada + The role will be changed to %s. Everyone in the chat will be notified. + Transparência + Alterne o perfil de chat para convites únicos. + Desbloquear membros para todos? + Para enviar + Condições atualizadas + O segundo operador predefinido no aplicativo! + Esta mensagem foi excluída ou ainda não foi recebida. + Ver condições atualizadas + Toque em Criar endereço SimpleX no menu para criá-lo mais tarde. + Usar porta TCP %1$s quando nenhuma porta for especificada. + A denúncia será arquivado para você. + Para receber + Esta ação não pode ser desfeita - as mensagens enviadas e recebidas neste chat antes da selecionada serão excluídas. + Para se proteger contra a substituição do seu link, você pode comparar os códigos de segurança dos contatos. + Menções não lidas + Porta TCP para mensagens + Usar porta web + O aplicativo protege sua privacidade usando diferentes operadores em cada conversa. + Quando mais de um operador está ativado, nenhum deles têm metadados para saber quem se comunica com quem. + Sim + Seu perfil de chat será enviado aos membros do chat + Ver condições + Usar para mensagens + Você pode definir o nome da conexão para lembrar com quem o link foi compartilhado. + Você pode configurar servidores nas configurações. + Usar %s + Usar servidores + Website + Você pode mencionar até %1$s membros por mensagem! + Final + Você pode copiar e reduzir o tamanho da mensagem para enviá-la. + Atualização + Você pode configurar operadores em Configurações de rede & servidores. + Usar para arquivos + Você deixará de receber mensagens deste chat. O histórico do chat será preservado. + Para fazer chamadas, permita usar seu microfone. Encerre a chamada e tente ligar novamente. + Os servidores para novos arquivos do seu perfil de chat atual + A conexão atingiu o limite de mensagens não entregues, seu contato pode estar offline. + Mensagens não entregues + Configurar operadores de servidor + Chats privados, grupos e seus contatos não são acessíveis aos operadores de servidor. + Aceitar + Ao usar o SimpleX Chat, você concorda em:\n- enviar apenas conteúdo legal em grupos públicos.\n- respeitar outros usuários – sem spam. + Política de privacidade e condições de uso. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index c9db7de2e6..544ee8af89 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -971,4 +971,4 @@ Todos os perfis Já conectando! Já entrando no grupo! - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index c02e17f568..92572516b9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -6,7 +6,7 @@ Acceptă Acceptă incognito Adaugă server - Setări de rețea avansate + Reglări avansate de rețea %1$s dorește să se conecteze cu tine prin Acceptă %1$d mesaje omise. @@ -17,7 +17,7 @@ Adițional secundar Acceptă 6 limbi noi pentru interfață - %1$d mesaje nu au putut fi decriptate. + %1$d mesaje nu au putut fi descifrate. %1$d mesaj(e) omis(e) %1$s MEMBRI 1 zi @@ -39,8 +39,8 @@ Adaugă servere prin scanare de coduri QR. Adaugă la un alt dispozitiv Adaugă mesaj de întâmpinare - acceptând criptarea… - acceptând criptarea pentru %s… + se acceptă cifrarea… + se acceptă cifrarea pentru %s… Administratorii pot crea linkuri de participare la grupuri. Câteva lucruri în plus Toate datele aplicației sunt șterse. @@ -50,7 +50,7 @@ Întrerupe schimbarea adresei Întrerupe Toate conversațiile și mesajele vor fi șterse - operațiunea este definitivă! - Toate mesajele vor fi șterse - operațiunea este definitivă! + Toate mesajele vor fi șterse - acest lucru nu poate fi anulat! Permite reacții la mesaje doar dacă și contactul tău le permite. Permite ștergerea ireversibilă a mesajelor trimise. (24 ore) Permite trimiterea de fișiere și media. @@ -122,7 +122,7 @@ Înapoi Creează grup secret Se creează link… - apel audio (necriptat e2e) + apel audio (necifrat e2e) Creează fișier Adaugă contact: pentru a crea un nou link de invitare, sau a te conecta printr-un link pe care l-ai primit.]]> Autentificare anulată @@ -150,7 +150,7 @@ creator Fundal Creează profil nou în aplicația desktop. 💻 - Aplicația criptează fișierele locale noi (cu excepția videoclipurilor). + Aplicația cifrează fișierele locale noi (cu excepția videoclipurilor). autor Arabă, Bulgară, Finlandeză, Ebraică, Thailandeză și Ucraineană - mulțumită utilizatorilor și Weblate. Apelurile audio/video sunt interzise. @@ -178,7 +178,7 @@ Repetă încărcarea apel respins Elimini fraza de acces din Keystore? - Elimini fraza de acces din setări? + Elimini parola din reglări? Repetă cererea de conectare? Necesar Reîncearcă @@ -188,10 +188,10 @@ Reîmprospătează Revoci fișierul? Revocă - Renegociezi criptarea? + Renegociezi cifrarea? Resetează Respinge - Salvează fraza de acces în setări + Salvează parola din reglări Salvează și actualizează profilul grupului Repetă cererea de alăturare? Repornește conversația @@ -206,7 +206,7 @@ Salvează parola profilului Salvează fraza de acces și deschide conversația %s și %s - Renegociază criptarea + Renegociază cifrarea Salvează și notifică contactul Salvează și notifică contactele Salvează și notifică membrii grupului @@ -214,7 +214,7 @@ Răspunde Revocă fișierul Salvează - Salvezi setările? + Salvezi reglările? Salvezi preferințe? Repornire Restabilește copia de rezervă a bazei de date @@ -241,8 +241,8 @@ Mesaj trimis Setează preferințele grupului trimis - Adresa serverului este incompatibilă cu setările de rețea. - Versiunea serverului este incompatibilă cu setările de rețea. + Adresa serverului este incompatibilă cu reglările rețelei. + Versiunea serverului este incompatibilă cu reglările rețelei. Trimițând prin trimiterea de fișiere nu este acceptată încă Scanează codul de securitate din aplicația contactului tău @@ -293,7 +293,7 @@ Arată: Arată erori interne secret - SETĂRI + REGLĂRI %s conectat setează imagine de profil Trimis către: %s @@ -307,7 +307,7 @@ Selectează Trimiterea de fișiere va fi oprită. Trimite - Setări + Reglări Scanează cod Cod de securitate Trimite-ne email @@ -334,7 +334,7 @@ Echipa SimpleX Închizi? Adresă de contact SimpleX - Link de grup SimpleX + Link pentru grup SimpleX Link-uri SimpleX Invitație unică SimpleX Siglă SimpleX @@ -361,7 +361,7 @@ Aplică pentru Începe o nouă conversație Stea pe GitHub - Criptare de la capăt la capăt standard + Cifrare standard de la un capăt la altul Pornește periodic Mereu Folosește mereu rutare privată. @@ -369,7 +369,7 @@ pornire… %s secunde Începi conversația? - Setări avansate + Reglări avansate Adresă desktop rea ID de mesaj incorect Hash de mesaj incorect @@ -391,7 +391,7 @@ Conectare incognito Contactul deja există Schimbă codul de acces - Poți porni Blocare SimpleX din Setări. + Poți porni Blocare SimpleX din Reglări. Conversații Alege un fișier Contactul tău trebuie să fie online pentru a se completa conexiunea. @@ -429,17 +429,17 @@ Crează Bluetooth Camera - Apeluri pe ecranul de blocare: - contactul are criptare e2e - contactul nu are criptare e2e + Apeluri pe ecranul blocat: + contactul are cifrare e2e + contactul nu are cifrare e2e Contacte - CONVERSAȚII + DISCUȚII BAZĂ DE DATE CONVERSAȚIE Baza de date a conversației ștearsă Conversația rulează Baza ta de date a conversațiilor - Baza ta de date a conversațiilor nu este criptată - setează frază de acces pentru a o proteja. - Fraza de acces de criptare a bazei de date va fi actualizată și stocată în setări. + Baza de date a conversațiilor tale nu este cifrată - pune o parolă pentru a o proteja. + Parola de cifrare a bazei de date va fi actualizată și stocată în reglări. ai schimbat rolul %s la %s Nu se pot invita contactele! Te-ai alăturat grupului @@ -462,7 +462,7 @@ Anulează mesajul live Conectare prin link / cod QR Contactele tale vor rămâne conectate. - Fraza de acces de criptare a bazei de date va fi actualizată. + Parola de cifrare a bazei de date va fi actualizată. Contactele tale pot permite ștergerea totală a mesajelor. Trebuie să permiți contactului tău să trimită mesaje vocale pentru a le putea trimite. Versiunede bază: v%s @@ -474,7 +474,7 @@ se schimbă adresa pentru %s… Și tu și contactul tău puteți trimite mesaje temporare. Nu se pot primi fișiere - Poate fi dezactivat în setări – notificările vor fi încă afișate cât timp aplicația rulează.]]> + Poate fi dezactivat din reglări – notificările vor fi afișate dacă aplicația este în funcțiune.]]> Verifică mesajele noi la fiecare 10 minute timp de până la 1 minut Nu ai conversații Contactul și toate mesajele vor fi șterse - acest lucru nu poate fi anulat! @@ -487,7 +487,7 @@ Profil conversație aldin Poți folosi markdown pentru a formata mesaje: - Apelând… + se apelează… Schimbă modul de autodistrugere Baza de date a conversației importată Trebuie să folosești cea mai recentă versiune a bazei de date a conversațiilor DOAR pe un singur dispozitiv, altfel se poate să nu mai primești mesajele de la unele contacte. @@ -500,10 +500,10 @@ Contact ascuns: Nume contact Crează adresă - Bază de date criptată! + Bază de date cifrată! Schimbi fraza de acces a bazei de date? Conversația este oprită - Poți porni conversația prin Setările aplicației / Bază de date sau repornind aplicația. + Poți porni discuția din Reglările aplicației / Baza de date sau repornind aplicația. ai ieșit Blochezi membrul? Blocat de admin @@ -523,9 +523,8 @@ Te conectezi la tine? Verifică conexiunea la internet și încearcă din nou Culori conversație - Temă conversație - Bun pentru baterie. -\nServiciul în fundal verifică mesaje la fiecare 10 minute. Ai putea rata apeluri sau mesaje urgente. + Aspectul discuției + Bun pentru baterie. Aplicația verifică mesajele la fiecare 10 minute. Pot fi pierdute apeluri sau mesaje urgente.]]> Negru Blochezi membrul pentru toți? Și tu și contactul tău puteți șterge ireversibil mesajele trimise. (24 de ore) @@ -554,7 +553,7 @@ Se conectează la desktop Rugat să primească imaginea Cerere de conexiune trimisă! - Vă rugăm să rețineți: mesajele și releurile pentru fișiere sunt conectate printr-un proxy SOCKS. Apelurile ți trimiterea de previzualizări a link-urilor folosesc o conexiune directă.]]> + De reținut: releele de mesaje și fișiere sunt conectate prin proxy SOCKS. Apelurile și trimiterea de previzualizări ale adreselor web utilizează conexiunea directă.]]> conectat Confirmați codul de access Confirmare actualizare bază de date @@ -573,16 +572,16 @@ Conexiunea la desktop este într-o stare proastă Conectare Conexiune - Ștergeți notițele private? + Ștergi notițele private? Conectare automată se conectează apelul… se conectează (invetație la introducere) se conectează… %s este într-o stare proastă]]> - Confirmați setările de rețea + Confirmă reglările rețelei se conectează… Eroare de conexiune (AUTENTIFICARE) - Optimizarea pentru baterie este activă, vom opri serviciile din fundal și cererile periodice pentru mesaje noi. Le puteți reactiva din setări. + Optimizarea bateriei este activă, dezactivând serviciul de funcționare în ascuns și solicitările periodice pentru mesaje noi. Pot fi reactivate din reglări. conectat Crează un grup: pentru a crea un nou grup.]]> Ștergeți conversația? @@ -592,7 +591,7 @@ Consolă conversație Confirmați parola colorat - Toate contactele, conversațiile și fișierele dumneavoastră vor fi encriptate într-un mod sigur și încărcate pe bucăți pe releurile XFTP configurate. + Toate contactele, conversațiile și fișierele tale vor fi cifrate într-un mod sigur și încărcate pe bucăți pe releurile XFTP configurate. Rugat să primească videoclipul Comparați fișierul Vă rugăm să rețineți: nu veți putea recupera sau schimba parola dacă o veți pierde.]]> @@ -609,7 +608,7 @@ Cel mai bun pentru baterie. Veți primi notificări doar când aplicația rulează (FĂRĂ servicii de fundal).]]> Se conectează apelul complet - Vă rugăm să rețineți: folosind aceeași bază de date pe două dispozitive, va intrerupe decripția mesajelor de la conexiunile dumneavoastră, ca protecție de securitate.]]> + De reținut: folosirea aceleiași baze de date în două aparate, va intrerupe descifrarea mesajelor din conexiunile tale, ca măsură de protecție.]]> Confirmați încărcarea Confirmați că țineți minte parola de la baza de date pentru a o migra. Conexiune @@ -635,7 +634,7 @@ Adresă desktop Dispozitive desktop Desktop - Eroare decriptare + Eroare de decriptare Șterge imagine Șterge după Șterge pentru toată lumea @@ -677,10 +676,10 @@ Contactul %1$s a schimbat la %2$s Contactul este șters. Controlează-ți rețeaua - Fraza de acces pentru criptarea bazei de date va fi actualizată și stocată în Keystore. + Parola pentru cifrarea bazei de date va fi actualizată și stocată în Keystore. Fraza de acces a bazei de date Servere XFTP configurate - Baza de date este criptată folosind o expresie de acces aleatorie. Schimbați-o înainte de a exporta. + Baza de date este cifrată folosind o parolă aleatorie. Trebuie schimbată înainte de exportare. apel conectare Contact șters! @@ -689,7 +688,7 @@ Verifică pentru actualizări Creează Estompează media - BAZĂ DE DATE CHAT + BAZĂ DE DATE DISCUȚIE Conectează-te cu prietenii mai ușor. încercări Completat @@ -714,10 +713,96 @@ Conectat Corectează numele la %s? Continuă - Baza de date este criptată folosind o expresie de acces aleatorie, o poți schimba + Baza de date este cifrată folosind o parolă aleatorie; o poți schimba. Nu se pot trimite mesaje membrului grupului Se conectează Servere conectate Nu se poate apela contactul - Se conectează la contact, te rog așteaptă sau verifică mai târziu! - \ No newline at end of file + Se conectează la contact, așteaptă sau verifică mai târziu! + Reglări avansate + Reglări + Reglările tale + apel vocal cifrat e2e + apel video cifrat e2e + APARAT + EXPERIMENTAL + Cifrează + erori la descifrare + TU + necifrat e2e + cifrat e2e + Apel video primit + Dezactivează + Permiți apeluri? + terminat + Sunete la apel + Apeluri îmbunătățite + Apel audio primit + Termină apelul + marcate şterse + %d mesaje marcate șterse + moderat de %s + %1$d alte erori de fișier. + %1$s mesaje netransmise + ai distribuit un link de unică folosință + ai distribuit un link ascuns de unică folosință + prin link pentru grup + ascuns prin link pentru grup + Prin browser + Reclamă + Conținut inadecvat + Încălcă normelor comunitare + Profil inadecvat + Alt motiv + Eroare la salvarea serverelor SMP + Asigurați-vă că adresele serverelor XFTP sunt în format corect, pe randuri separate și nu sunt duplicate. + Eroare la încărcarea serverlor XFTP + Eroare la actualizarea configurației de rețea + Eșec la încărcarea conversațiilor + Vă rugăm să actualizați aplicația și contactați dezvoltatorii. + Eroare la crearea profilului! + %d mesaje blocate + Eroare de renegociere a criptării + invitat să se conecteze + ascuns prin link pentru adresa de contact + printr-un link de unică folosință + 1 raport + 1 an + înaintat + eroare de afișare a mesajului + eroare de afișat conținutul + criptare cap-coadă cu secretizare înaintată perfecta, repudiere si recuperare în caz de spargere.]]> + criptare cap-coadă rezistentă la algoritmi cuantici cu secretizare înaintată perfecta, repudiere si recuperare în caz de spargere.]]> + Această conversație este protejată prin criptare cap-coadă. + Link întreg + ascuns printr-un link de unică folosință + a + b + prin %1$s + Eroare la încărcarea serverlor SMP + %1$d fișier(e) șterse. + raport arhivat de %s + invitație acceptată + Asigurați-vă că adresele serverelor SMP sunt în format corect, pe randuri separate și nu sunt duplicate. + Eroare la salvarea serverelor XFTP + %1$d fișier(e) încă în descărcare. + %1$d fișier(e) a eșuat să se descărcarce. + %1$d fișier(e) nu au fost descărcate. + prin link pentru adresa de contact + Deschiderea link-ului în browser poate reduce confidențialitatea și securitatea conexiunii. Link-urile SimpleX de neîncredere vor fi roșii. + solicitat să se conecteze + Eșec la încărcarea conversației + Doar tu și moderatorii vedeți asta + Doar expeditorul şi moderatorii văd asta + raport arhivat + %d mesaje blocate de admin + primirea de fișiere nu este acceptată încă + tu + format mesaj necunoscut + format mesaj invalid + LIVE + moderat + conversație nevalidă + date nevalide + Această conversație este protejată prin criptare cap-coadă, rezistentă la algoritmi cuantici. + Notițe private + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index f234a2cf0a..dae44e06af 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -754,7 +754,7 @@ Вы пытаетесь пригласить инкогнито контакт в группу, где Вы используете свой основной профиль Пригласить членов группы - УЧАСТНИКОВ ГРУППЫ: %1$s + %1$s ЧЛЕНОВ ГРУППЫ Вы: %1$s Удалить группу Удалить группу? @@ -1019,7 +1019,7 @@ Сообщение будет удалено для всех членов группы. Сообщение будет помечено как удаленное для всех членов группы. Пожалуйста, свяжитесь с админом группы. - Вы не можете отправлять сообщения! + Вы \"читатель\" только чтение сообщений читатель Роль при вступлении @@ -2027,8 +2027,7 @@ Подключенные серверы Ранее подключенные серверы Проксированные серверы - Начиная с %s. -\nВсе данные хранятся только на вашем устройстве. + Начиная с %s.\nВсе данные хранятся только на вашем устройстве. Переподключить сервер для устранения неполадок доставки сообщений. Это использует дополнительный трафик. Ошибка Ошибка переподключения к серверу @@ -2232,7 +2231,7 @@ Прозрачность Децентрализация сети Второй оператор серверов в приложении! - Включить Flux + Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных. для лучшей конфиденциальности метаданных. Улучшенная навигация в разговоре Посмотреть измененные условия @@ -2297,8 +2296,8 @@ Серверы по умолчанию Роль будет изменена на %s. Все участники разговора получат уведомление. Ваш профиль будет отправлен участникам разговора. - %s.]]> - %s.]]> + %s.]]> + %s.]]> Условия использования Дополнительные серверы файлов и медиа Ошибка сохранения сервера @@ -2320,13 +2319,206 @@ Нет серверов для доставки сообщений. Вы можете настроить серверы позже. SimpleX Chat и Flux заключили соглашение добавить серверы под управлением Flux в приложение. - Приложение улучшает конфиденциальность используя разных операторов в каждом разговоре. + Приложение защищает вашу конфиденциальность, используя разные операторы в каждом разговоре. Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем. Ошибка сохранения серверов Условия будут приняты для включенных операторов через 30 дней. Ошибка приема условий Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности. - Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux. + Например, если ваш контакт получает сообщения через сервер SimpleX Chat, ваше приложение будет доставлять их через сервер Flux. Прямые сообщения между членами запрещены в этом разговоре. - \ No newline at end of file + Группы + Удалить + Удалить список? + Все + Изменить порядок + Избранное + запрошено соединение + Редактировать + Предприятия + Включить журналы + О операторах + Ошибка при сохранении базы данных + Соединение не готово. + Ошибка обновления списка чата + Ошибка создания списка чатов + Список + Никаких чатов в списке %s. + Без непрочитанных чатов + Никаких чатов + Чаты не найдены + Все чаты будут удалены из списка %s, а сам список удален + Добавить список + Примечания + Открыто с %s + Создать список + Добавить в список + Изменить список + Сохранить список + Имя списка... + Исправить соединение? + Соединение требует повторного согласования шифрования. + Исправление + Выполняется повторное согласование шифрования. + принятое приглашение + Ошибка при загрузке списков чатов + Контакты + Название списка и эмодзи должны быть разными для всех списков. + Пожаловаться + Спам + Пожаловаться на спам: увидят только модераторы группы. + Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены + Получайте уведомления от упоминаний. + Сообщения о нарушениях запрещены в этой группе. + Пожаловаться на нарушение: увидят только модераторы группы. + Установить имя чата… + Улучшенная производительность групп + Приватные названия медиафайлов. + Спам + Сообщения о нарушениях + Непрочитанные упоминания + Да + Упоминайте участников 👋 + Улучшенная приватность и безопасность + Ускорено удаление групп. + Ускорена отправка сообщений. + Помогайте администраторам модерировать их группы. + Организуйте чаты в списки + Вы можете сообщить о нарушениях + Установите время исчезания сообщений в чатах. + Вы можете упомянуть до %1$s пользователей в одном сообщении! + Причина сообщения? + Эта жалоба будет архивирована для вас. + Разрешить отправлять сообщения о нарушениях модераторам. + Содержание нарушает условия использования + Ошибка чтения пароля базы данных + сообщение о нарушении заархивировано %s + Нарушение правил группы + Неприемлемое сообщение + Другая причина + Неприемлемый профиль + %d сообщений о нарушениях + Ошибка создания сообщения о нарушении + Соединение заблокировано + Соединение заблокировано сервером оператора:\n%1$s. + Спросить + Отключить автоматическое удаление сообщений? + Удалить сообщения с вашего устройства. + Отключить удаление сообщений + по умолчанию (%s) + Все сообщения о нарушениях будут заархивированы для вас. + Архивировать все сообщения о нарушениях? + Архивировать %d сообщений о нарушениях? + Для меня + Архивировать сообщение о нарушении + Архивировать сообщения о нарушениях + Удалить сообщение о нарушении + Файл заблокирован оператором сервера:\n%1$s. + Для всех модераторов + 1 сообщение о нарушении + Измененить автоматическое удаление сообщений? + 1 год + Не пропустите важные сообщения. + Ошибка сохранения настроек + заархивированное сообщение о нарушении + архивировать + Архивировать сообщение о нарушении? + Пароль не может быть прочитан из Keystore. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. + Пароль не может быть прочитан из Keystore, пожалуйста, введите его. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. + модератор + ожидает утверждения + ожидает + Обновленные условия + Запретить жаловаться модераторам группы. + Члены группы могут пожаловаться модераторам. + Сообщения в этом чате никогда не будут удалены. + Открыть ссылку из списка чатов + Открыть веб-ссылку? + Пожаловаться на профиль: увидят только модераторы группы. + Сообщения о нарушениях + Пожаловаться: увидят только модераторы группы. + Выключить уведомления для всех + Использовать TCP-порт %1$s, когда порт не указан. + Использовать TCP-порт 443 только для серверов по умолчанию. + Все серверы + Серверы по умолчанию + Нет + Использовать веб-порт + Нет + Пожаловаться на сообщение: увидят только модераторы группы. + отклонён + Сообщение о нарушении: %s + TCP-порт для отправки сообщений + Открыть ссылку + отклонён + Только отправитель и модераторы видят это + Только вы и модераторы видят это + Разблокировать членов для всех? + Сообщения от этих членов группы будут показаны! + Все новые сообщения от этих членов группы будут скрыты! + Заблокировать членов для всех? + Члены группы будут удалены - это действие нельзя отменить! + Участники будут удалены из разговора - это действие нельзя отменить! + модераторы + Удалить членов группы? + Принять + Используя SimpleX Chat, Вы согласны:\n- отправлять только законные сообщения в публичных группах.\n- уважать других пользователей – не отправлять спам. + Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. + Настроить операторов серверов + Политика конфиденциальности и условия использования. + все + Принять + Участник хочет присоединиться к группе. Принять? + группа удалена + удален из группы + %d чата(ов) + контакт не готов + контакт удален + не синхронизирован + запрос на вступление отклонён + Новый участник хочет присоединиться к группе. + Пожалуйста, подождите, пока модераторы группы рассмотрят ваш запрос на вступление. + ожидает одобрения + Отклонить + Отклонить участника? + Ошибка при удалении чата с членом группы + Полная ссылка + Ошибка вступления члена группы + Ссылка не поддерживается + Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку. + %d сообщений + Вы можете найти Ваши жалобы в Чате с админами. + Чат с админами + Чат с членом группы + выключено + Одобрять членов + Чаты с членами группы + Приём членов в группу + Одобрять членов для вступления в группу. + Нет чатов с членами группы + Принять как читателя + Принять в группу + Принять члена + одобрен админами + Жалоба отправлена модераторам + Вы вышли + нельзя отправлять + %d чатов с членами группы + контакт выключен + член имеет старую версию + Вы не можете отправлять сообщения! + Короткая ссылка + Сохранить настройки вступления? + Вы приняли этого члена + рассмотрение + Установить вступление в группу + Удалить чат с членом группы? + Удалить разговор + принят %1$s + Чат с админами + Вы приняты + 1 чат с членом группы + SimpleX ссылка канала + Короткие ссылки (БЕТА) + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index ebf57836b5..b4d854c3d1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1328,4 +1328,4 @@ ในการตอบกลับถึง ไม่มีประวัติ encryptionใช้ได้ - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 503d82158f..257daec596 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -93,7 +93,7 @@ Tercihleri kaydet\? Profil parolasını kaydet Profil sadece konuştuğun kişilerle paylaşılır. - Gizli iletişimin\ngelecek kuşağı + Mesajlaşmanın geleceği Ses kapalı Doğrulama iptal edildi Yeniden başlat @@ -264,7 +264,7 @@ Kullanıldığında bütün veriler silinir. Kendiliğinden yok olan mesajlar Kişilerinin sana, kendiğinden yok olan mesajlar göndermesine izin ver. - Bu grupta kendiliğinden yok olan mesajlara izin verilmiyor. + Kendiliğinden yok olan mesajlara izin verilmiyor. %1$d mesajlar deşifrelenemedi. %1$s ÜYELER %1$d atlanılmış mesaj(lar) @@ -605,7 +605,7 @@ Yedekleri geri yükledikten sonra şifrelemeyi onar. Fransız arayüzü Daha da azaltılmış pil kullanımı - Grup üyeleri, mesajlara tepki ekleyebilir. + Üyeler, mesajlara tepki ekleyebilir. Grup profili, üyelerinin aygıtlarında barındırılmaktadır, sunucularda değil. Gizle Gizle @@ -648,9 +648,9 @@ Grup tam adı: Dosya ve medya Grup üyeleri doğrudan mesaj gönderebilir. - Grup üyeleri, gönderilen mesajları kalıcı olarak silebilir. (24 saat içinde) + Üyeler, gönderilen mesajları kalıcı olarak silebilir. (24 saat içinde) Grup üyeleri sesli mesaj gönderebilirler. - Bu toplu konuşmada, dosya ve medya yasaklanmıştır. + Dosya ve medya yasaklanmıştır. Grup üyeleri dosya ve medya paylaşabilir. Grup bağlantıları Konuşmada devre dışı bırakıldığında bile @@ -690,7 +690,7 @@ Android Keystore parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar. Karşılama mesajı Karşılama mesajı - Bu grupta sesli mesajlar yasaktır. + Sesli mesajlar yasaktır. Neler yeni %s sürümünde yeni Daha fazla bilgi edinin @@ -704,7 +704,7 @@ Anlık bildirimler Anlık bildirimler Anlık bildirimler devre dışı! - SimpleX in arkaplanda çalışmasına izin ver seçeneğini işaretleyin. Aksi takdirde, bildirimler devre dışı bırakılacaktır.]]> + izin verin. ]]> Kişi ismi Cihaz doğrulaması devre dışı. SimpleX Kilidi Kapatılıyor. Cihaz doğrulaması etkin değil. Cihaz doğrulamasını etkinleştirdikten sonra SimpleX Kilidini Ayarlar üzerinden açabilirsiniz. @@ -730,7 +730,7 @@ Arkadaşlarınızı davet edin kalın italik - Daha sonra ayarlardan değiştirebilirsiniz. + Pili nasıl etkiler kişi uçtan uca şifrelemeye sahiptir kişi uçtan uca şifrelemeye sahip değildir Sohbet durduruldu @@ -758,7 +758,7 @@ Japonca ve Portekizce kullanıcı arayüzü Kişiler davet edilemiyor! Davetin süresi dolmuş! - Geri alınamaz mesaj silme bu grupta yasaktır + Geri alınamaz mesaj silme yasaktır Mesaj gönderildi bilgisi! Gruba katılınıyor tek seferlik gizli bağlantı paylaştınız @@ -835,7 +835,7 @@ Alıcı adresini değiştir Mesaj tepkileri Tercihleriniz - Mesaj tepkileri bu grupta yasaklıdır + Mesaj tepkileri yasaklıdır. Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. Zaten %1$s e bağlısınız Doğrulanamadınız; lütfen tekrar deneyin. @@ -931,7 +931,7 @@ - daha stabil mesaj iletimi. \n- biraz daha iyi gruplar. \n- ve daha fazlası! - 2 katmanlı uçtan uca şifreleme ile kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar.]]> + Sadece istemci cihazlar kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar. Grup tercihlerini sadece grup sahipleri değiştirebilir. metin yok Ağ durumu @@ -940,7 +940,7 @@ Kilit modunu değiştir Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. Lütfen doğru bağlantıyı kullandığınızı kontrol edin veya irtibat kişinizden size başka bir bağlantı göndermesini isteyin. - SimpleX arka plan hizmeti kullanılır - günde pilin yüzde birkaçını kullanır.]]> + SimpleX arka planda çalışır.]]> Periyodik bildirimler Periyodik bildirimler devre dışı Bildirimleri almak için lütfen veri tabanı parolasını girin @@ -949,7 +949,7 @@ Hatırlayın veya güvenli bir şekilde saklayın - kaybolan bir parolayı kurtarmanın bir yolu yoktur! Sohbet konsolunu aç - Sohbet profillerini aö + Sohbet profillerini değiştir. Bu metin ayarlarda mevcut Filtrelenmiş sohbet yok Çok fazla görsel! @@ -1022,7 +1022,7 @@ Mesaj taslağı Sohbet profillerini parola ile koru! Daha az pil kullanımı - Gizliliği korumak için, diğer tüm platformlar gibi kullanıcı kimliği kullanmak yerine, SimpleX mesaj kuyrukları için kişilerinizin her biri için ayrı tanımlayıcılara sahiptir. + Gizliliği korumak için, SimpleX her bir konuşma için farklı bir ID kullanır. Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). Alıcılar güncellemeleri siz yazdıkça görürler. Bilgilerinizi kullanarak giriş yapın @@ -1201,7 +1201,7 @@ Mobilden tara Bağlantıları onayla Mesaj paylaş… - Önceki mesajın hash\'i farklı. + Önceki mesajın hash\'i farklı.\" SimpleX Kilit aktif değil! SimpleX Kilit Doğrudan bağlanılsın mı? @@ -1713,7 +1713,7 @@ SimpleX bağlantısı gönderimini yasakla Eşzamanlılık alınıyor SimpleX bağlantıları - SimpleX bağlantıları bu grupta yasaklandı + SimpleX bağlantıları yasaklandı Kulaklık Hoparlör Kulaklıklar @@ -2053,14 +2053,14 @@ Ses kapatıldı Yüklendi Sunucu istatistikleri sıfırlanacaktır - bu geri alınamaz! - Erişilebilir sohbet araç çubuğu + Erişilebilir uygulama araç çubukları Görünüm ayarlarından değiştirebilirsiniz. Sohbet listesini değiştir: Sistem modu Erişilebilir sohbet araç çubuğu İçin bilgi gösteriliyor İstatistikler - %s\'den başlayarak.\nTüm veriler cihazınıza özeldir. + %s\'den başlayarak.\nTüm veriler cihazınızda gizli tutulur. Bir proxy aracılığıyla gönderildi Sunucu adresi Yükleme hataları @@ -2124,4 +2124,103 @@ sadece bir kişiyle kullanılabilir - yüz yüze veya herhangi bir mesajlaşma programı aracılığıyla paylaşın]]> %s.]]> %s.]]> - \ No newline at end of file + Arkadaş ekle + İş konuşmaları + Takım üyesi ekle + kabul edilmiş davet + İş adresi + Takım üyelerini konuşmalara ekle + Uygulama her zaman arka planda çalışır + Operatörler hakkında + a+b + Liste ekle + Hepsi + Listeye ekle + %s listesindeki bütün sohbetler kaldırılacak ve liste silinecek + İşletmeler + arşivlenmiş rapor + Başka bir sebep + Raporu arşivle + %s tarafından arşivlenen rapor + 1 rapor + %s.]]> + Sor + 1 yıl + Xiaomi cihazları: Bildirimlerin çalışması için lütfen sistem ayarlarında Otomatik Başlat\'ı etkinleştirin.]]> + Tüm raporlar sizin için arşivlenecektir. + Tüm raporlar arşivlensin mi? + Raporları arşivle + %s.]]> + %s operatörü için de geçerli olacaktır.]]> + %s operatörü(leri) için de geçerli olacaktır.]]> + Mesajların moderatörlere bildirilmesine izin verin. + %d raporu arşivleyelim mi? + Arşiv + Raporu arşivleyelim mi? + uçtan uca şifreli olarak gönderilir ve doğrudan mesajlarda kuantum sonrası güvenlik sağlanır.]]> + Veritabanı şifresini okurken hata oluştu + Sohbeti sil + Sohbet silinsin mi? + Bağlantı hazır değil. + Kullanım şartları + daha iyi üstveri gizliliği için. + Sunucuları kaydederken hata oluştu + Şartlar 30 gün sonra etkin operatörler için kabul edilecektir. + Raporu sil + Listeyi değiştir + %s için de geçerli olacaktır.]]> + Üyeler herkes için engellensin mi? + Topluluk kurallarının ihlali + Şartları kabul ederken hata oluştu + Sil + Liste silinsin mi? + Bağlantı güvenliği + İçerik kullanım şartlarını ihlal ediyor + Rapor oluşturulurken hata oluştu + Bağlantı engellendi + Bağlantı sunucu operatörü tarafından engellendi:\n%1$s. + Dosya sunucu operatörü tarafından engellendi:\n%1$s. + %d rapor + Mesaj çok büyük! + Otomatik silinen mesajlar değiştirilsin mi? + Otomatik silinen mesajlar devre dışı bırakılsın mı? + Liste oluştur + Sırayı değiştir + Düzenle + Sosyal medya için + Tek kullanımlık bağlantı oluştur + Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir. + varsayılan (%s) + Bu üyelerden gelen yeni mesajların hepsi gizlenecektir. + İyileştirilmiş grup performansı + Sohbet zaten var! + %1$s ile bağlısınız.]]> + Konuşma profili %s için: + Üyeler arası doğrudan mesajlaşma yasaklıdır. + Şartlar %s tarihinde etkin operatörler için otomatik olarak kabul edilecektir. + Sohbet mesajlarını cihazınızdan silin. + Her 10 dakikada mesajları kontrol et + Devam et + İyileştirilmiş gizlilik ve güvenlik + Daha hızlı mesaj gönderme. + Bu sohbette üyeler arası doğrudan mesajlaşma yasaklıdır. + Sohbet listesi yüklenirken hata oluştu + Sohbet listesi oluşturulurken hata oluştu + Sohbet listesini güncellerken hata oluştu + Sık kullanılanlar + Konuşmalar + Veritabanını kaydederken hata oluştu + Sohbet sizin için silinecek - bu geri alınamaz! + %s için de geçerli olacaktır.]]> + Bütün moderatörler için + Benim için + Ayarlar kaydedilirken hata oluştu + Günlükleri etkinleştir + Sohbet + Grupların daha hızlı silinmesi. + Sohbet bütün üyeler için silinecek - bu geri alınamaz! + Sunucu eklerken hata oluştu + Onar + Bağlantı onarılsın mı? + Sunucuyu güncellerken hata oluştu + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index eff112717e..a962298f19 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -18,8 +18,8 @@ 1-разове посилання Про SimpleX Chat Додавайте сервери, скануючи QR-коди. - Всі чати і повідомлення будуть видалені - цю дію неможливо скасувати! - Дозволяйте дзвінки тільки в разі дозволу вашого контакту. + Усі чати та повідомлення будуть видалені - цю дію неможливо скасувати! + Дозволити дзвінки тільки за умови, що ваш контакт дозволяє їх. Дозволити безповоротне видалення повідомлень, тільки якщо ваш контакт дозволяє вам. (24 години) Дозволити голосові повідомлення\? Пароль застосунку замінено паролем самознищення. @@ -29,41 +29,41 @@ Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. Адміністратори можуть створювати посилання для приєднання до групи. Збірка додатку: %s - Дозволяйте голосові повідомлення тільки в разі дозволу вашого контакту. - Дозволяйте надсилати повідомлення, які зникають. + Дозволити голосові повідомлення тільки за умови, що ваш контакт дозволяє їх. + Дозволити надсилати зникаючі повідомлення. прийнятий виклик Завжди використовувати реле ДОДАТОК - Дозволяйте надсилати прямі повідомлення учасникам. + Дозволити надсилання приватних повідомлень учасникам. Дозволити безповоротно видаляти надіслані повідомлення. (24 години) Дозволяйте надсилати голосові повідомлення. Дозволити реакції на повідомлення. Вся інформація стирається при його введенні. Пароль для додатка ІКОНКА ДОДАТКУ - Дозволяйте повідомлення, які зникають, тільки якщо ваш контакт дозволяє їх. + Дозволити зникаючі повідомлення тільки за умови, що ваш контакт дозволяє їх. Дозвольте вашим контактам додавати реакції на повідомлення. - Дозволяйте реакції на повідомлення тільки в разі дозволу вашого контакту. + Дозволити реакції на повідомлення тільки за умови, що ваш контакт дозволяє їх. Створений порожній профіль чату з наданим ім\'ям, і застосунок відкривається, як завжди. Додатковий акцент Дозволити вашим контактам безповоротно видаляти надіслані повідомлення. (24 години) Дозволити Розширені налаштування мережі Отримувати доступ до серверів через SOCKS-проксі на порті %d? Проксі має бути запущено до активації цієї опції. - Всі ваші контакти залишаться підключеними. - Всі дані застосунку буде видалено. + Усі ваші контакти залишаться підключеними. + Усі дані застосунку буде видалено. Після перезапуску додатка або зміни ключової фрази буде використано сховище ключів Android для безпечного збереження ключової фрази - це дозволить отримувати сповіщення. Дозвольте вашим контактам надсилати голосові повідомлення. Прийняти інкогніто Додати сервер адміністратор Додати привітання - Всі члени групи залишаться підключеними. + Усі учасники групи залишаться підключеними. Дозвольте вашим контактам надсилати повідомлення, які зникають. - Всі повідомлення будуть видалені - цю дію неможливо скасувати! Повідомлення будуть видалені ЛИШЕ для вас. + Усі повідомлення будуть видалені - цю дію неможливо скасувати! Повідомлення будуть видалені ЛИШЕ для вас. Версія додатку Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Оновлення профілю буде відправлено вашим контактам. - Всі ваші контакти залишаться підключеними. Оновлення профілю буде відправлено вашим контактам. + Усі ваші контакти залишаться підключеними. Оновлення профілю буде відправлено вашим контактам. Відповісти на виклик Адреса Додати профіль @@ -224,12 +224,11 @@ Скасувати приглушення Ви запросили контакт Контакт, якому ви поділилися посиланням, НЕ зможе підключитися! - заповнювач зображення профілю + аватар не встановлено QR-код довідка покажіть QR-код у відеовиклику, або поділіться посиланням.]]> - Ваш профіль чату буде відправлено -\nвашому контакту + Ваш профіль буде відправлено \nвашому контакту Одноразове запрошення Невірний код безпеки! Для перевірки end-to-end шифрування порівняйте (або скануйте) код на своїх пристроях. @@ -271,7 +270,7 @@ Як це впливає на батарею Миттєво Виклик вже завершено! - Ваші виклики + Виклики Ваші сервери ICE Відкрити через реле @@ -376,7 +375,7 @@ так Налаштування контакту Безпека SimpleX Chat була перевірена компанією Trail of Bits. - Ваші контакти можуть дозволити повне видалення повідомлень. + Ваші контакти можуть дозволити остаточне видалення повідомлень. База даних буде зашифрована. Помилка сховища ключів Невідома помилка @@ -390,7 +389,7 @@ Стан мережі Оновити налаштування мережі\? Локальні дані профілю тільки - Ваш випадковий профіль + Випадковий профіль ввімкнено ввімкнено для вас Забороняйте надсилання прямих повідомлень учасникам. @@ -417,7 +416,7 @@ Пропущений виклик Підключення виклику Конфіденційність і безпека - Ваша конфіденційність + Конфіденційність НАЛАШТУВАННЯ ДОПОМОГА ПІДТРИМАЙТЕ SIMPLEX CHAT @@ -443,7 +442,7 @@ Створити посилання на групу Змінити роль у групі\? Помилка при вилученні учасника - Ваш профіль чату буде відправлений учасникам групи + Ваш профіль буде відправлений учасникам групи Видалення для всіх Голосові повідомлення Голосові повідомлення заборонені в цьому чаті. @@ -451,20 +450,20 @@ Встановіть його замість системної аутентифікації. Вимкнути\? Поділитися з контактами - Ваш профіль зберігається на вашому пристрої і обмінюється лише з ваших контактів. Сервери SimpleX не можуть його бачити. - Зберегти і повідомити контакти - Зберегти і повідомити учасників групи + Ваш профіль зберігається на вашому пристрої та ділиться лише з вашими контактами. Серверам SimpleX профіль недоступний. + Зберегти та сповістити контакти + Зберегти та сповістити учасників Вийти без збереження Сховати профіль Пароль для відображення Створити без зашифрування e2e контакт має зашифрування e2e - Хеш попереднього повідомлення відрізняється. + Хеш попереднього повідомлення інший. Підтвердити пароль Новий пароль Перезапустити - Ваша база даних чату + База даних чату Чат зупинено БАЗА ДАНИХ ЧАТУ Новий архів бази даних @@ -497,7 +496,7 @@ Забороняйте надсилання повідомлень, які зникають. Забороняйте невідворотне видалення повідомлень. Учасники можуть надсилати голосові повідомлення. - %dm + %dм Нове в %s Самознищуючий пароль Італійський інтерфейс @@ -516,7 +515,7 @@ Зберігайте останню чернетку повідомлення із вкладеннями. Зникне повідомлення Надіслати зникне повідомлення - зображення профілю + аватар Більше Створити профіль GitHub.]]> @@ -529,7 +528,7 @@ Встановити на 1 день Забороняйте реакції на повідомлення. Реакції на повідомлення заборонені в цьому чаті. - %ds + %dс хвилини Китайський та іспанський інтерфейс підключення %1$d @@ -554,7 +553,7 @@ Блокування SimpleX Щоб захистити вашу інформацію, увімкніть блокування SimpleX. \nВам буде запропоновано завершити аутентифікацію перед увімкненням цієї функції. - Увійти за допомогою своїх облікових даних + Пройдіть аутентифікацію Увімкнути блокування SimpleX Блокування SimpleX не увімкнено! Поділитися @@ -569,8 +568,8 @@ Файл буде видалено з серверів. Відкликати несанкціонована відправка - Ласкаво просимо, %1$s! - Ласкаво просимо! + Вітаємо, %1$s! + Вітаємо! Цей текст доступний у налаштуваннях вас запрошено в групу Поділитися повідомленням… @@ -580,7 +579,7 @@ Одночасно можна відправити лише 10 відео Помилка декодування зображення Неможливо декодувати зображення. Спробуйте інше зображення або зв\'яжіться з розробниками. - Зображення + Фото Зображення буде отримано, коли ваш контакт завершить його вивантаження. Зображення буде отримано, коли ваш контакт буде в мережі, будь ласка, зачекайте або перевірте пізніше! Зображення збережено в галереї @@ -609,8 +608,8 @@ Логотип SimpleX Електронна пошта Цей QR-код не є посиланням! - Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, зачекайте або перевірте пізніше! - Вас підключать, коли ваш запит на з\'єднання буде прийнятий, зачекайте або перевірте пізніше! + Ви будете підключені до групи, коли пристрій власник групи буде в мережі, зачекайте або перевірте пізніше! + Підключення відбудеться, коли ваш запит на підключення буде прийнято. Будь ласка, зачекайте або спробуйте пізніше! Поділитися 1-разовим посиланням Дізнатися більше Щоб підключитися, ваш контакт може сканувати QR-код або використовувати посилання у додатку. @@ -639,8 +638,8 @@ Ви можете створити його пізніше Ваш поточний профіль Видалити зображення - Зберегти уподобання? - Зберегти і повідомити контакт + Зберегти налаштування? + Зберегти та сповістити контакт Зберегти пароль профілю Пароль схованого профілю Профіль обмінюється лише з вашими контактами. @@ -718,10 +717,10 @@ Зникає о: %s (поточне) Вилучити учасника - Роль буде змінено на "%s". Всі учасники групи будуть сповіщені. + Роль буде змінено на %s. Усі учасники групи будуть сповіщені. Роль буде змінено на "%s". Учасник отримає нове запрошення. Група - Ласкаво просимо + Привітальне повідомлення Профіль групи зберігається на пристроях учасників, а не на серверах. Зберегти профіль групи Профіль і підключення до серверів @@ -733,7 +732,7 @@ Світлий Помилка імпорту теми Налаштування групи - Повідомлення зникнення + Зникаючі повідомлення ввімкнено для контакту вимкнено отримано, заборонено @@ -757,7 +756,7 @@ %d година %d тиждень %d тижні - %dw + %dтиж запропоновано %s: %2s З опційним вітанням. Приховуйте екран додатка в останніх програмах. @@ -785,7 +784,7 @@ Ви вже підключені до %1$s через це посилання. Режим інкогніто СЕРВЕРИ - Зберегти ласкаво просимо? + Зберегти вітальне повідомлення? Отримання через Приглушено, коли неактивно! Видалити профіль @@ -827,11 +826,11 @@ Інший час Створити одноразове запрошення Сканувати QR-код - Зображення + Фото Відео Прийняте вами з\'єднання буде скасоване! Контакт ще не підключений! - Тестування серверів + Тестувати сервери Зберегти сервери Ваш сервер Тест сервера не вдався! @@ -877,7 +876,7 @@ Надіслано о: %s Видалено о: %s %s (поточне) - %dh + %dч %d день %d днів скасовано %s @@ -938,7 +937,7 @@ Тільки власники груп можуть увімкнути голосові повідомлення. Відхилити Очистити чат\? - зображення попереднього перегляду посилання + зображення прев’ю посилання скасувати попередній перегляд посилання Налаштування Виклик у процесі @@ -986,7 +985,7 @@ Змінити роль Ви все ще отримуватимете дзвінки та сповіщення від приглушених профілів, коли вони активні. %d місяці - %dmth + %dміс Надіслані повідомлення будуть видалені після встановленого часу. Відкриття бази даних… Помилка встановлення адреси @@ -1004,12 +1003,12 @@ Підключитися за посиланням / QR-кодом Очистити Неправильний QR-код - Вас підключать, коли пристрій вашого контакту буде в мережі, зачекайте або перевірте пізніше! + Підключення відбудеться, коли пристрій вашого контакту буде онлайн. Будь ласка, зачекайте або спробуйте пізніше! Ви не втратите свої контакти, якщо ви пізніше видалите свою адресу. Коли люди просять про з\'єднання, ви можете його прийняти чи відхилити. Посібнику користувача.]]> SimpleX-адреса - Очистити перевірку + Скинути підтвердження %s перевірено %s не перевірено Написати нам ел. листа @@ -1025,7 +1024,7 @@ Обов\'язково КОЛЬОРИ ІНТЕРФЕЙСУ Створіть адресу, щоб дозволити людям підключатися до вас. - Ваші контакти залишаться підключеними. + Контакти залишатимуться підключеними. Створити SimpleX-адресу Оновлення профілю буде відправлено вашим контактам. Зупинити поділ адреси? @@ -1069,12 +1068,12 @@ Привітання Вибрати контакти Поділитися адресою - Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите членів групи, якщо потім видалите її. + Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите учасників групи, якщо потім видалите її. Ви можете поділитися цією адресою зі своїми контактами, щоб вони могли підключитися до %s. Локальна назва Ідентифікатор бази даних Попередній перегляд - Введіть ласкаво просимо… + Введіть привітальне повідомлення… Змінити адресу отримання Створити секретну групу Повністю децентралізовано - видимо тільки для учасників. @@ -1088,7 +1087,7 @@ Ви дозволяєте ні вимк - Встановити налаштування групи + Налаштування групи Налаштування Прямі повідомлення Помилка @@ -1109,7 +1108,7 @@ Видалити контакт Встановити ім\'я контакту… Файл - З галереї + Галерея Команда SimpleX хоче підключитися до вас! ЕКСПЕРИМЕНТАЛЬНІ ФУНКЦІЇ @@ -1134,7 +1133,7 @@ Підключитися Щоб показати ваш схований профіль, введіть повний пароль у поле пошуку на сторінці Ваші профілі. Підтвердити пароль - %dd + %dд Захистіть свої чат-профілі паролем! Помилка дешифрування Сервер вимагає авторизації для завантаження, перевірте пароль @@ -1146,7 +1145,7 @@ Дзвінки чату SimpleX Служба сповіщень Показати попередній перегляд - Попередній перегляд сповіщень + Перегляд сповіщень Запускається, коли додаток відкритий Запускається періодично Текст повідомлення @@ -1200,16 +1199,14 @@ Експортувати тему Переконайтеся, що файл має правильний синтаксис YAML. Експортуйте тему, щоб мати приклад структури файлу теми. Скинути кольори - за замовчуванням (%s) + типово (%s) Тільки ви можете здійснювати дзвінки. Тільки ваш контакт може здійснювати дзвінки. Що нового Голосові повідомлення За профілем чату (типово) або за підключенням (BETA). Власні теми - - голосові повідомлення до 5 хвилин. -\n- власний час на зникнення. -\n- історія редагування. + - голосові повідомлення до 5 хвилин.\n- налаштування часу для зникнення повідомлень.\n- історія змін. Японський та португальський інтерфейс Натисніть, щоб приєднатися Натисніть, щоб приєднатися анонімно @@ -1229,7 +1226,7 @@ Змінити ключову фразу бази даних? Не вдається отримати доступ до сховища ключів для збереження пароля бази даних Зберегти ключову фразу і відкрити чат - Ключова фраза не знайдена в сховищі ключів, будь ласка, введіть її вручну. Це може трапитися, якщо ви відновили дані додатка за допомогою інструменту резервного копіювання. Якщо це не так, зверніться до розробників. + Пароль не знайдено в сховищі ключів, введіть його вручну. Це могло статися, якщо ви відновили дані додатка за допомогою інструмента резервного копіювання. Якщо це не так, зверніться до розробників. Видалити профіль чату %d сек Пошук @@ -1431,7 +1428,7 @@ Ви вже подали запит на підключення за цією адресою! надіслати приватне повідомлення Показувати консоль в новому вікні - Всі нові повідомлення від %s будуть приховані! + Усі нові повідомлення від %s будуть приховані! підключив(лась) безпосередньо заблоковано Блокувати учасників групи @@ -1439,32 +1436,32 @@ Підключений робочий стіл Новий мобільний пристрій Підключати автоматично - Адреса робочого столу + Адреса комп\'ютера Одночасно може працювати лише один пристрій Підключіть мобільний і десктопний додатки! 🔗 Через безпечний квантовостійкий протокол. - Використовувати з робочого столу у мобільному додатку і скануйте QR-код.]]> + Використовувати з комп\'ютера в мобільному додатку та відскануйте QR-код.]]> Щоб приховати небажані повідомлення. Несумісна версія (новий)]]> - Відсунути відсилання до робочого столу? + Відключити комп\'ютер? Кращі групи - Параметри пов\'язаних робочих столів - Пов\'язані робочі столи + Параметри пов\'язаних комп\'ютерів + Підключені комп\'ютери Виявити через локальну мережу Інкогніто групи Цей пристрій %s був відключений]]> - Очікування робочого столу… + Очікування комп\'ютера… Швидше приєднуйтесь та надійшовні повідомлення. - Пов\'язані мобільні + Під’єднані мобільні Робочий стіл - Підключено до робочого столу + Підключено до комп\'ютера Назва цього пристрою Завантаження файлу - Підключення до робочого столу + Підключення до комп\'ютера Знайдено робочий стіл - Пристрої робочого столу + Комп\'ютери Не сумісно! Зв\'язати з мобільним Використовувати з комп\'ютера @@ -1477,13 +1474,13 @@ Перевірте код на мобільному Введіть назву цього пристрою… Помилка - Підключитися до робочого столу + Підключитися до комп\'ютера Відключити автор Підключено до мобільного - Некоректна адреса робочого столу - Вставити адресу робочого столу - Перевірити код з робочим столом + Некоректна адреса комп\'ютера + Вставити адресу комп\'ютера + Перевірити код з комп\'ютером Сканувати QR-код з комп\'ютера Пристрої Виявлено через локальну мережу @@ -1494,7 +1491,7 @@ Перевірити підключення Відключити робочий стіл? Будь ласка, зачекайте, поки файл завантажується з підключеного мобільного - Версія робочого столу %s не сумісна з цим додатком. + Версія комп\'ютерного додатка %s несумісна з цим додатком. Перевірити підключення Чат зупинено. Якщо ви вже використовували цю базу даних на іншому пристрої, перенесіть її назад перед запуском чату. З зашифрованими файлами та медіа. @@ -1556,7 +1553,7 @@ \n \nРекомендується перезапустити додаток. Надсилати до 100 останніх повідомлень новим користувачам. - До 100 останніх повідомлень надсилаються новим членам. + До 100 останніх повідомлень надсилаються новим учасникам. З\'єднання перервано Створено: %s Показати внутрішні помилки @@ -1611,7 +1608,7 @@ учасник %1$s змінений на %2$s вилучено зображення профілю Установлено нову адресу контакту - Установлено нове зображення профілю + встановити новий аватар оновлений профіль вилучено адресу контакту Колишній учасник %1$s @@ -1620,7 +1617,7 @@ Аудіодзвінок Помилка відкриття браузера Для використання дзвінків потрібен браузер за замовчуванням. Будь ласка, налаштуйте браузер за замовчуванням в системі та надайте більше інформації розробникам. - Перехід з іншого пристрою + Перенести з іншого пристрою квантово-стійке шифрування e2e стандартне наскрізне шифрування Цей чат захищений наскрізним шифруванням. @@ -1648,8 +1645,8 @@ Повторний імпорт Завершіть міграцію на іншому пристрої. Застосувати - Перенести пристрій - Перехід на інший пристрій + Міграція пристрою + Перенести на інший пристрій Помилка експорту бази даних чату Налаштування збереження помилок Помилка завантаження архіву @@ -1660,7 +1657,7 @@ Помилка видалення бази даних Для того, щоб продовжити, чат слід зупинити. Зупинка чату - %s завантажено + %s вивантажено Завантаження архіву Створення архівного посилання Видалити базу даних з цього пристрою @@ -1677,7 +1674,7 @@ Перевірте підключення до Інтернету та спробуйте ще раз Переконайтеся, що ви пам\'ятаєте пароль до бази даних для її перенесення. Помилка при перевірці парольної фрази: - Всі ваші контакти, розмови та файли будуть надійно зашифровані та завантажені частинами на налаштовані XFTP-реле. + Усі ваші контакти, розмови та файли будуть надійно зашифровані та завантажені частинами на налаштовані XFTP-реле. Please note: використання однієї і тієї ж бази даних на двох пристроях порушить розшифровку повідомлень з ваших з\'єднань, як захист безпеки.]]> Скасувати міграцію Чат перемістився! @@ -1751,10 +1748,10 @@ Надайте дозвіл(и) на здійснення дзвінків Відкрити налаштування ФАЙЛИ - Зображення профілю + Зображення профілів Підключення до мережі адміністратори - всі учасники + всіх учасників Литовський інтерфейс Надавати дозволи Кольори чату @@ -1829,7 +1826,7 @@ Так Отримання паралелізму Посилання SimpleX заборонені. - Сформуйте зображення профілю + Форма зображень профілю При підключенні аудіо та відеодзвінків. Скинути колір Система @@ -1846,7 +1843,7 @@ Налаштовані XFTP сервери Бета Статус файлу - Повідомлення надіслано + Надіслано повідомлень Статистика Попередньо підключені сервери Помилковий ключ або невідома адреса чанка файлу - найбільш імовірно, що файл було видалено. @@ -1948,13 +1945,12 @@ Поточний профіль Деталі Помилки - Отримання повідомлення - Повідомлення отримано + Отримання повідомлень + Отримані повідомлення В очікуванні Проксіровані сервери Показувати інформацію для - Починаючи з %s. -\nВсі дані зберігаються лише на вашому пристрою. + Починаючи з %s.\nУсі дані зберігаються приватно на вашому пристрої. Перепідключити сервер для примусової доставки повідомлень. Це використовує додатковий трафік. Скинути всю статистику Скинути всю статистику? @@ -1981,8 +1977,8 @@ Завантажені файли Помилки завантаження Частини видалені - Частини завантажено - Частини завантажено + Частин завантажено + Частин вивантажено Ця посилання було використано на іншому мобільному пристрої, створіть нове посилання на комп\'ютері. Помилка копіювання Будь ласка, перевірте, що мобільний пристрій і комп\'ютер підключені до однієї локальної мережі, і що брандмауер комп\'ютера дозволяє з\'єднання. @@ -2029,9 +2025,9 @@ Проксірований Надіслати помилки Завершено - Всі профілі + Усі профілі Скинути - Завантажено + Вивантажено Видалити %d повідомлень учасників? Повідомлення будуть позначені для видалення. Одержувач(і) зможуть розкрити ці повідомлення. Вибрати @@ -2062,7 +2058,7 @@ Видалити архів? Поділитися профілем Завантажений архів бази даних буде остаточно видалено з серверів. - Ваше з\'єднання було перенесено на %s, але виникла несподівана помилка під час перенаправлення на профіль. + Підключення було перенесено до %s, але під час перенаправлення на профіль сталася непередбачена помилка. Режим системи Не використовуйте облікові дані з проксі. Аутентифікація проксі @@ -2140,24 +2136,24 @@ Прийняті умови Умови будуть автоматично прийняті для увімкнених операторів: %s. Оператор мережі - %s серверів + %s сервери Вебсайт Ваші сервери - Використовуйте %s - Використовуйте сервери + Використовувати %s + Використовувати сервери %s.]]> %s.]]> Прийняти умови - Умови перегляду + Переглянути умови Додано сервери повідомлень Для приватної маршрутизації - Щоб отримати - Використовуйте для файлів + Для отримання повідомлень + Використовувати для файлів Додано медіа та файлові сервери - Відкриті зміни + Відкрити зміни Відкриті умови Сервери для нових файлів вашого поточного профілю чату - Щоб відправити + Для відправки Помилка додавання сервера Сервер оператора Сервер додано до оператора %s. @@ -2183,7 +2179,7 @@ Текст поточних умов не вдалося завантажити, ви можете переглянути умови за цим посиланням: Помилки в конфігурації серверів. Умови приймаються з: %s. - Увімкнути flux + Увімкніть Flux у налаштуваннях мережі та серверів для кращої конфіденційності метаданих Умови приймаються до: %s. Продовжити Створити одноразове посилання @@ -2195,7 +2191,7 @@ SimpleX адреса або одноразове посилання? Новий сервер Немає серверів для отримання файлів. - Умови перегляду + Переглянути умови Немає серверів для надсилання файлів. Попередньо встановлені сервери Протокол сервера змінено. @@ -2204,14 +2200,14 @@ З\'єднання досягло ліміту недоставлених повідомлень, ваш контакт може бути офлайн. Натисніть Створити адресу SimpleX у меню, щоб створити її пізніше. Додаток захищає вашу конфіденційність, використовуючи різних операторів у кожній розмові. - Використовуйте для повідомлень + Використовувати для повідомлень Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. Або імпортуйте архівний файл Віддалені мобільні Пристрої Xiaomi: будь ласка, увімкніть Автозапуск у налаштуваннях системи, щоб сповіщення працювали.]]> Повідомлення занадто велике! Будь ласка, зменшіть розмір повідомлення або видаліть медіа та надішліть знову. - Додайте учасників команди до розмов. + Додайте учасників команди до розмови. Бізнес адреса Перевіряти повідомлення кожні 10 хвилин. Без фонової служби @@ -2242,9 +2238,154 @@ Будь ласка, зменшіть розмір повідомлення та надішліть знову. Скопіюйте та зменшіть розмір повідомлення для відправки. Ви припините отримувати повідомлення з цього чату. Історія чату буде збережена. - Ваш профіль чату буде надіслано учасникам чату. + Ваш профіль буде надіслано учасникам чату. Коли увімкнено більше ніж одного оператора, жоден з них не має метаданих, щоб дізнатися, хто спілкується з ким. прийнято запрошення запит на підключення Про операторів - \ No newline at end of file + SimpleX Chat та Flux уклали угоду про включення серверів, що працюють на Flux, до додатку. + Підключення вимагає повторного узгодження шифрування. + Виконується повторне узгодження шифрування. + Виправити + Виправити підключення? + Увімкнути журнали + Помилка збереження бази даних + a + b + закреслити + Підключення не готове. + Відкрити за допомогою %s + Немає чатів у списку %s. + Немає непрочитаних чатів + всі + Зберегти список + Видалити + Видалити список? + Назва списку... + Усі чати буде видалено зі списку %s, а сам список видалено + Контакти + Помилка створення списку чату + Чати не знайдено + Створити список + Вибране + Без чатів + Редагувати + Помилка завантаження списків чату + Помилка оновлення списку чату + Групи + Компанії + Додати до списку + Додати список + Список + Назва списку та емодзі мають відрізнятися для всіх списків. + Нотатки + Список змін + Змінити порядок + Помилка збереження налаштувань + Помилка при створенні звіту + Тільки ви та модератори бачать це + Повідомити про спам: тільки модератори групи побачать це. + Порушення правил спільноти + модератор + Причина повідомлення? + Тільки відправник і модератори бачать це + Повідомити + Повідомити про порушення: тільки модератори групи побачать це. + Повідомлення буде архівоване для вас. + Інша причина + архівование повідомлення + Архів + Неприпустимий контент + Повідомити про контент: тільки модератори групи побачать це. + Архівувати повідомлення? + Повідомити про профіль учасника: тільки модератори групи побачать це. + Неприпустимий профіль + Спам + Повідомити інше: тільки модератори групи побачать це. + Архівувати повідомлення + архівоване повідомлення від %s + Видалити повідомлення + 1 повідомлення + %d повідомлень + Повідомлення учасників + Повідомлення + Вміст порушує умови використання + Спам + Файл заблоковано оператором сервера: \n%1$s. + Так + Відкрити посилання + Відкрити вебпосилання? + Підключення заблоковано + Запитати + Відкрити посилання зі списку чату + Підключення заблоковано оператором сервера: \n%1$s. + Ні + Назвати чат… + Повідомлення в цьому чаті ніколи не будуть видалені. + Видалити повідомлення чату з вашого пристрою. + Вимкнути автоматичне видалення повідомлень? + Вимкнути видалення повідомлень + 1 рік + типово (%s) + Змінити автоматичне видалення повідомлень? + Цю дію не можна скасувати — повідомлення, надіслані та отримані в цьому чаті раніше за обраний час, будуть видалені. + TCP-порт для повідомлень + Використовувати TCP-порт %1$s, якщо порт не вказано. + Використовувати веб-порт + Вимкнути звук + Непрочитані згадки + Ви можете згадувати до %1$s учасників у кожному повідомленні! + Усі звіти будуть архівовані для вас. + Архівувати всі звіти? + Архівувати %d звітів? + Архівувати звіти + Для всіх модераторів + Для мене + Звіт: %s + Заборонити повідомлення модераторам. + Повідомлення модераторам заборонено в цій групі. + Учасники можуть повідомляти повідомлення модераторам. + Дозволити повідомляти повідомлення модераторам. + відхилено + відхилено + Краща продуктивність груп + Не пропускайте важливі повідомлення. + Отримуйте сповіщення, коли вас згадують. + Допоможіть адміністраторам модерувати їхні групи. + Організовуйте чати в списки + Приватні імена медіа-файлів. + Надсилати приватні звіти + Краща конфіденційність та безпека + Швидша відправка повідомлень. + Згадуйте учасників 👋 + Встановлюйте термін придатності повідомлень у чатах. + Швидше видалення груп. + Помилка зчитування пароля бази даних + Пароль у сховищі ключів не можна зчитати. Це могло статися після оновлення системи, несумісного з додатком. Якщо це не так, зверніться до розробників. + Пароль у сховищі ключів не можна зчитати, введіть його вручну. Це могло статися після оновлення системи, несумісного з додатком. Якщо це не так, зверніться до розробників. + очікує + очікує на схвалення + Видалити учасників? + Усі нові повідомлення від цих учасників будуть приховані! + Заблокувати учасників для всіх? + Учасників буде видалено з групи – це неможливо скасувати! + модератори + Повідомлення від цих учасників буде показано! + Учасників буде видалено з чату – це неможливо скасувати! + Розблокувати учасників для всіх? + Оновлені умови + Приватні чати, групи та ваші контакти недоступні для операторів сервера. + Прийняти + Використовуючи SimpleX Chat, ви погоджуєтесь на:\n- надсилати тільки легальний контент у публічних групах.\n- поважати інших користувачів – без спаму. + Налаштувати операторів сервера + Політика конфіденційності та умови використання + Використовувати короткі посилання (BETA) + Це посилання вимагає новішої версії додатку. Будь ласка, оновіть додаток або попросіть вашого контакту надіслати сумісне посилання. + Повне посилання + Коротке посилання + Посилання на канал SimpleX + Несумісне посилання для підключення + Усі сервери + Ні + Типові сервери + Використовуйте TCP порт 443 лише для попередньо налаштованих серверів. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index c9b30c652a..3cbc54f652 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -7,7 +7,7 @@ Địa chỉ Hủy bỏ Chấp nhận - liên kết dùng một lần + đường dẫn dùng 1 lần Thêm liên hệ Thông tin về SimpleX Chat Thêm máy chủ @@ -41,7 +41,7 @@ 1 tháng 1 tuần Hủy bỏ việc đổi địa chỉ - Các quản trị viên có thể tạo liên kết để tham gia nhóm. + Các quản trị viên có thể tạo đường dẫn để tham gia nhóm. Một ngày Biến thể của màu thứ cấp Tất cả các thành viên trong nhóm vẫn sẽ được giữ kết nối. @@ -74,7 +74,7 @@ Cho phép gửi tin nhắn tự xóa. Cho phép gửi tin nhắn thoại. Cho phép thả biểu tượng cảm xúc. - Cho phép gửi các tập tin, ảnh và video. + Cho phép gửi các tệp và đa phương tiện. Luôn luôn bật Tất cả liên hệ của bạn vẫn sẽ được giữ kết nối. Tất cả liên hệ của bạn vẫn sẽ được giữ kết nối. Bản cập nhật hồ sơ sẽ được gửi tới các liên hệ của bạn. @@ -122,7 +122,7 @@ Các cuộc gọi thoại/video tất cả các thành viên Các cuộc gọi thoại/video bị cấm. - Cho phép gửi liên kết SimpleX. + Cho phép gửi đường dẫn SimpleX. quản trị viên tác giả Quá trình xác thực đã bị hủy bỏ @@ -136,7 +136,7 @@ Dịch vụ nền luôn luôn chạy - thông báo sẽ được hiển thị ngay khi nhận được tin nhắn. Tự động chấp nhận yêu cầu liên hệ Địa chỉ máy tính xấu - Thêm liên hệ: để tạo liên kết mời mới, hoặc kết nối qua liên kết bạn nhận được.]]> + Thêm liên hệ: để tạo đường dẫn mời mới, hoặc kết nối qua đường dẫn bạn nhận được.]]> hàm băm tin nhắn xấu " \nCó sẵn ở v5.1" @@ -169,7 +169,7 @@ in đậm Cả bạn và liên hệ của bạn đều có thể gửi tin nhắn tự xóa. Cả bạn và liên hệ của bạn đều có thể thả cảm xúc tin nhắn. - Xin lưu ý: relay tin nhắn và tệp được kết nối thông qua SOCKS proxy. Các cuộc gọi và bản xem trước liên kết sử dụng kết nối trực tiếp.]]> + Xin lưu ý: relay tin nhắn và tệp được kết nối thông qua SOCKS proxy. Các cuộc gọi và bản xem trước đường dẫn sử dụng kết nối trực tiếp.]]> Tốt cho pin. Ứng dụng kiểm tra tin nhắn 10 phút một lần. Bạn có thể bỏ lỡ các cuộc gọi hoặc tin nhắn khẩn cấp.]]> Luôn luôn đang gọi… @@ -200,7 +200,7 @@ Camera và mic Không thể mời liên hệ! Không thể truy cập Keystore để lưu mật khẩu cơ sở dữ liệu - hủy bỏ xem trước liên kết + hủy bỏ xem trước đường dẫn Hủy bỏ di chuyển Chủ đề ứng dụng Không thể nhận tệp @@ -208,34 +208,34 @@ đã hủy bỏ %s Không thể mời liên hệ! Hủy bỏ tin nhắn động - đã thay đổi quyền hạn của %s thành %s + đã thay đổi chức vụ của %s thành %s Dung lượng lưu trữ vượt quá giới hạn - người nhận không thể nhận được tin nhắn vừa gửi trước đó. đã thay đổi địa chỉ cho bạn Thay đổi mật khẩu cơ sở dữ liệu? - đã thay đổi quyền hạn của bạn thành %s + đã thay đổi chức vụ của bạn thành %s Thay đổi chế độ khóa Di động Đổi mật khẩu - Thay đổi quyền hạn của nhóm? + Thay đổi chức vụ nhóm? Thay đổi Thay đổi địa chỉ nhận? Thay đổi địa chỉ nhận Thay đổi chế độ tự hủy Thay đổi mã tự hủy - Thay đổi quyền hạn + Thay đổi chức vụ đang thay đổi địa chỉ… đang thay đổi địa chỉ… Bảng điều khiển trò chuyện - Ứng dụng SimpleX Chat đang hoạt động - Cơ sở dữ liệu SimpleX Chat đã bị xóa - Ứng dụng SimpleX Chat đã được dừng lại. Nếu bạn đã sử dụng cơ sở dữ liệu này trên một thiết bị khác, bạn nên chuyển nó trở lại trước khi bắt đầu ứng dụng. - Cơ sở dữ liệu SimpleX Chat đã được nhập - Ứng dụng SimpleX Chat đã được dừng lại + Kết nối trò chuyện đang hoạt động + Cơ sở dữ liệu trò chuyện đã bị xóa + Kết nối trò chuyện đã được dừng lại. Nếu bạn đã sử dụng cơ sở dữ liệu này trên một thiết bị khác, bạn nên chuyển nó trở lại trước khi khởi động kết nối. + Cơ sở dữ liệu trò chuyện đã được nhập + Kết nối trò chuyện đã được dừng lại đang thay đổi địa chỉ cho %s… Tùy chọn trò chuyện Màu trò chuyện - CƠ SỞ DỮ LIỆU SIMPLEX CHAT - Ứng dụng SimpleX Chat đã được dừng lại + CƠ SỞ DỮ LIỆU TRÒ CHUYỆN + Kết nối trò chuyện đã được dừng lại Cơ sở dữ liệu đã được di chuyển! Các cuộc trò chuyện CÁC CUỘC TRÒ CHUYỆN @@ -248,17 +248,17 @@ Kiểm tra kết nối internet của bạn và thử lại Chủ đề trò chuyện Chế độ màu - Xác minh xóa - Xóa + Xác minh dọn dẹp + Dọn dẹp Nút đóng - Xóa ghi chú riêng tư? + Dọn dẹp ghi chú riêng tư? có màu - Xóa cuộc trò chuyện - Xóa + Dọn dẹp cuộc trò chuyện + Dọn dẹp Sắp ra mắt! Di chuyển từ một thiết bị kháctrên thiết bị mới và quét mã QR.]]> - Xóa - Xóa cuộc trò chuyện? + Dọn dẹp + Dọn dẹp cuộc trò chuyện? Cài đặt cấu hình cho các máy chủ ICE Xác nhận các cài đặt mạng Xác nhận mã truy cập @@ -302,10 +302,10 @@ đang kết nối (lời mời giới thiệu) Kết nối tới máy tính đang ở trong tình trạng không tốt Kết nối đã bị ngắt - Kết nối thông qua liên kết / mã QR + Kết nối qua đường dẫn / mã QR Kết nối tới chính bạn? Yêu cầu kết nối đã được gửi! - Kết nối thông qua liên kết + Kết nối qua đường dẫn Đang kết nối cuộc gọi Kết nối đã bị ngắt Đang kết nối tới máy tính @@ -315,21 +315,21 @@ Kết nối đã bị ngắt %s đang ở trong tình trạng không tốt]]> Kết nối tới máy tính - Hết thời gian chờ kết nối + Thời gian chờ kết nối Lỗi kết nối Kết nối - Kết nối thông qua liên kết? + Kết nối qua đường dẫn? kết nối %1$d kết nối đã được tạo lập Kết nối với %1$s? Lỗi kết nối (AUTH) Kết nối đang kết nối cuộc gọi… - Kết nối thông qua địa chỉ liên lạc? - Kết nối thông qua liên kết dùng một lần? + Kết nối qua địa chỉ liên lạc? + Kết nối qua đường dẫn dùng một lần? Liên hệ đã được kiểm tra Các liên hệ - Liên hệ có cho phép + Liên hệ cho phép Liên hệ đã tồn tại liện hệ %1$s đã thay đổi thành %2$s Liên hệ này vẫn chưa được kết nối! @@ -354,8 +354,8 @@ Không thể gửi tin nhắn Tạo tệp Tạo một địa chỉ để cho mọi người kết nối với bạn. - Tạo liên kết nhóm - Tạo liên kết + Tạo đường dẫn nhóm + Tạo đường dẫn Được tạo ra tại Tạo địa chỉ Được tạo ra tại: %s @@ -370,8 +370,8 @@ Các màu chế độ tối Tối Lỗi nghiêm trọng - Tạo liên kết lời mời dùng một lần - Đang tạo liên kết… + Tạo đường dẫn lời mời dùng một lần + Đang tạo đường dẫn… Tạo hồ sơ Tùy chỉnh và chia sẻ các chủ đề màu sắc. Các chủ đề tùy chỉnh @@ -388,7 +388,7 @@ Tạo địa chỉ SimpleX Tạo hồ sơ Tạo hàng đợi - Tạo liên kết lưu trữ + Tạo đường dẫn lưu trữ tùy chỉnh Chủ đề tối Hạ cấp cơ sở dữ liệu @@ -412,7 +412,7 @@ \nQuá trình này có thể mất một vài phút. Mật khẩu cơ sở dữ liệu và xuất dữ liệu Mật khẩu cơ sở dữ liệu khác với mật khẩu được lưu trong Keystore. - Mật khẩu cơ sở dữ liệu là cần thiết để mở ứng dụng SimpleX Chat. + Mật khẩu cơ sở dữ liệu là cần thiết để mở kết nối trò chuyện. Lỗi cơ sở dữ liệu Xóa Xóa địa chỉ? @@ -447,10 +447,10 @@ Xóa nhóm Xóa kết nối đang chờ? Xóa tệp cho tất cả các hồ sơ trò chuyện - Xóa liên kết? + Xóa đường dẫn? Xóa hồ sơ Xóa cho mọi người - Xóa liên kết + Xóa đường dẫn Xóa ảnh Xóa các tin nhắn Xóa nhóm? @@ -464,7 +464,7 @@ Xóa hàng đợi Công cụ nhà phát triển THIẾT BỊ - Tùy chọn nhà phát triển + Tùy chọn cho nhà phát triển Xác thực thiết bị đã bị vô hiệu hóa. Tắt Khóa SimpleX. Lỗi máy chủ đích: %1$s Chỉ báo đã nhận! @@ -491,9 +491,9 @@ Các máy chủ đã kết nối Lỗi xác nhận Đã hoàn thành - Các khúc đã bị xóa - Các khúc đã được tải xuống - Các khúc đã được tải lên + Các khối đã bị xóa + Các khối đã được tải xuống + Các khối đã được tải lên Hồ sơ hiện tại lỗi giải mã Thống kê chi tiết @@ -535,12 +535,12 @@ Đã bị tắt Tắt đã bị tắt - Tin nhắn tự xóa bị cấm trong nhóm này. + Tin nhắn tự xóa bị cấm. gọi kết nối Liên hệ sẽ bị xóa - điều này không thể hoàn tác! Cuộc trò chuyện đã bị xóa! - Làm mờ phương tiện truyền thông + Làm mờ đa phương tiện Không thể gọi liên hệ Cho phép thực hiện cuộc gọi? Cuộc gọi bị cấm! @@ -550,7 +550,7 @@ Không thể gọi thành viên nhóm Xác nhận xóa liên hệ? Liên hệ đã bị xóa! - Các liên hệ đã lưu trữ + Các liên hệ được lưu trữ Tạo Mờ hình ảnh để riêng tư hơn. Kiểm soát mạng của bạn @@ -559,7 +559,7 @@ Bất kỳ ai cũng có thể tạo máy chủ. Tiếp tục Kết nối nhanh hơn với bạn bè. - Cơ sở dữ liệu SimpleX Chat đã được xuất + Cơ sở dữ liệu trò chuyện đã được xuất Lưu trữ các liên hệ để trò chuyện sau. Ngắt kết nối máy tính? Xóa tối đa 20 tin nhắn cùng một lúc. @@ -592,18 +592,18 @@ Không hiển thị lại Tải về tệp tin Đang tải về kho lưu trữ - Hạ cấp và mở SimpleX Chat + Hạ cấp và mở kết nối trò chuyện Tải về Không bật Đã tải về Đang tải về bản cập nhật ứng dụng, đừng đóng ứng dụng - Các tập tin đã tải về + Các tệp đã tải về Lỗi tải về mã hóa đầu cuối Tải xuống các phiên bản mới từ GitHub. %d giây %ds - Đang tải xuống chi tiết liên kết + Đang tải xuống chi tiết đường dẫn Tên hiển thị trùng lặp! %d tuần %d tuần @@ -637,7 +637,7 @@ cho phép tái đàm phán mã hóa với %s cần tái đàm phán mã hóa cho %s Bật chỉ báo đã nhận? - Mã hóa các tệp và phương tiện được lưu trữ + Mã hóa các tệp và đa phương tiện được lưu trữ Lỗi Kết thúc cuộc gọi Bật trong cuộc trò chuyện trực tiếp (BETA)! @@ -682,25 +682,25 @@ Lỗi tạo địa chỉ Lỗi chấp nhận yêu cầu liên hệ Lỗi thay đổi địa chỉ - Lỗi xóa cơ sở dữ liệu SimpleX Chat + Lỗi xóa cơ sở dữ liệu trò chuyện Lỗi tạo liên hệ thành viên Lỗi hủy bỏ thay đổi địa chỉ - Lỗi tạo liên kết nhóm - Lỗi thay đổi quyền hạn + Lỗi tạo đường dẫn nhóm + Lỗi thay đổi chức vụ Lỗi kết nối đến máy chủ chuyển tiếp %1$s. Vui lòng thử lại sau. Lỗi xóa liên hệ Lỗi xóa nhóm - Lỗi xóa liên kết nhóm + Lỗi xóa đường dẫn nhóm Lỗi xóa yêu cầu liên hệ Lỗi xóa cơ sở dữ liệu Lỗi xóa kết nối liên hệ đang chờ xử lý - Lỗi xuất cơ sở dữ liệu SimpleX Chat + Lỗi xuất cơ sở dữ liệu trò chuyện Lỗi mã hóa cơ sở dữ liệu Lỗi bật chỉ báo đã nhận! Lỗi xóa ghi chú riêng tư Lỗi xóa hồ sơ người dùng - Lỗi nhập cơ sở dữ liệu SimpleX Chat - Lỗi xuất cơ sở dữ liệu SimpleX Chat + Lỗi nhập cơ sở dữ liệu trò chuyện + Lỗi xuất cơ sở dữ liệu trò chuyện Lỗi tải xuống kho lưu trữ Lỗi khởi tạo WebView. Cập nhật hệ điều hành của bạn lên phiên bản mới. Vui lòng liên hệ với nhà phát triển. \nLỗi: %s @@ -718,15 +718,15 @@ Lỗi kết nối lại máy chủ Lỗi Lỗi khôi phục thống kê - Lỗi cập nhật liên kết nhóm + Lỗi cập nhật đường dẫn nhóm lỗi hiển thị tin nhắn lỗi hiển thị nội dung Lỗi lưu máy chủ ICE Lỗi lưu máy chủ XFTP Lỗi lưu máy chủ SMP Lỗi gửi tin nhắn - Lỗi khởi động ứng dụng - Lỗi dừng ứng dụng + Lỗi khởi động kết nối trò chuyện + Lỗi dừng kết nối trò chuyện Lỗi hiển thị thông báo, liên hệ với nhà phát triển. Lỗi lưu mật khẩu người dùng Lỗi lưu cài đặt @@ -737,7 +737,7 @@ Dù đã tắt trong cuộc trò chuyện. Lỗi cập nhật cấu hình mạng Lỗi cập nhật quyền riêng tư người dùng - Mở rộng chọn quyền hạn + Mở rộng chọn chức vụ THỬ NGHIỆM Mở rộng Thoát mà không lưu @@ -749,14 +749,14 @@ Lỗi tải lên kho lưu trữ Tập tin đã xuất không tồn tại TẬP TIN - Không thể tải tin nhắn - Không tìm thấy tập tin - có thể tập tin đã bị xóa và hủy bỏ. - Lỗi tập tin + Không thể tải các cuộc trò chuyện + Không tìm thấy tệp - có thể tập tin đã bị xóa và hủy bỏ. + Lỗi tệp Xuất chủ đề - Không thể tải tin nhắn + Không thể tải cuộc trò chuyện Tập tin Nhanh chóng và không cần phải đợi người gửi hoạt động! - Không tìm thấy tập tin + Không tìm thấy tệp Tập tin: %s Tham gia nhanh chóng hơn và xử lý tin nhắn ổn định hơn. Tập tin @@ -764,19 +764,19 @@ Tập tin Lỗi máy chủ tệp: %1$s Trạng thái tệp - Tệp và phương tiện truyền thông không được cho phép - Tệp và phương tiện truyền thông bị cấm trong nhóm này. + Tệp và đa phương tiện không được cho phép + Tệp và đa phương tiện bị cấm. Tệp sẽ bị xóa khỏi máy chủ. - Tệp & phương tiện truyền thông - Tệp và phương tiện truyền thông bị cấm! + Tệp & đa phương tiện + Tệp và đa phương tiện bị cấm! Tệp sẽ được nhận khi liên hệ của bạn hoàn tất quá trình tải lên. - Tệp và phương tiện truyền thông - Tệp đã bị xóa hoặc liên kết không hợp lệ + Tệp và đa phương tiện + Tệp đã bị xóa hoặc đường dẫn không hợp lệ Tệp đã được lưu Tệp sẽ được nhận khi liên hệ của bạn hoạt động, vui lòng chờ hoặc kiểm tra lại sau! Trạng thái tệp: %s Lấp đầy - CƠ SỞ DỮ LIỆU SIMPLEX CHAT + CƠ SỞ DỮ LIỆU TRÒ CHUYỆN Lỗi chuyển đổi hồ sơ Lọc các cuộc hội thoại chưa đọc và các cuộc hội thoại yêu thích. Cuối cùng, chúng ta đã có chúng! 🚀 @@ -828,26 +828,26 @@ Chào buổi chiều! Giao diện tiếng Pháp Đã tìm thấy máy tính - Liên kết đầy đủ + Toàn bộ đường dẫn Từ Thư viện Tên đầy đủ: Tuyệt đối phi tập trung - chỉ hiển thị cho thành viên. Chào buổi sáng! nhóm đã bị xóa - Các thành viên nhóm có thể thả cảm xúc tin nhắn. - Các thành viên nhóm có thể xóa theo cách không thể hồi phục các tin nhắn đã gửi. (24 giờ) - Các thành viên trong nhóm có thể gửi tin nhắn thoại. + Các thành viên có thể thả cảm xúc tin nhắn. + Các thành viên có thể xóa các tin nhắn đã gửi mà không thể phục hồi lại. (24 giờ) + Các thành viên có thể gửi tin nhắn thoại. Cấp quyền - Liên kết nhóm + Đường dẫn nhóm Nhóm Tên đầy đủ nhóm: - Các thành viên trong nhóm có thể gửi tin nhắn tự xóa. - Các thành viên trong nhóm có thể gửi tệp và phương tiện truyền thông. - Các thành viên trong nhóm có thể gửi liên kết SimpleX. + Các thành viên có thể gửi tin nhắn tự xóa. + Các thành viên có thể gửi tệp và đa phương tiện. + Các thành viên có thể gửi đường dẫn SimpleX. Nhóm không hoạt động Nhóm đã tồn tại rồi! - Các thành viên trong nhóm có thể gửi tin nhắn trực tiếp. - Liên kết nhóm + Các thành viên có thể gửi tin nhắn trực tiếp. + Đường dẫn nhóm Lời mời nhóm đã hết hạn Lời mới nhóm không còn có hiệu lực, nó đã bị xóa bởi người gửi. Cho phép thực hiện cuộc gọi @@ -907,15 +907,15 @@ Ngay lập tức Hình ảnh sẽ được nhận khi liên hệ của bạn hoàn thành việc tải lên. Hình ảnh - Nếu bạn không thể gặp mặt trực tiếp, cho liên hệ của bạn xem mã QR trong một cuộc gọi video, hoặc chia sẻ liên kết. + Nếu bạn không thể gặp mặt trực tiếp, cho liên hệ của bạn xem mã QR trong một cuộc gọi video, hoặc chia sẻ đường dẫn. Hình ảnh đã được gửi Hình ảnh Hình ảnh đã được lưu vào Thư viện - Nếu bạn nhận được liên kết mời SimpleX Chat, bạn có thể mở nó trong trình duyệt của mình: - quét mã QR trong cuộc gọi video, hoặc liên hệ của bạn có thể chia sẻ một liên kết mời.]]> + Nếu bạn nhận được đường dẫn mời SimpleX Chat, bạn có thể mở nó trong trình duyệt của mình: + quét mã QR trong cuộc gọi video, hoặc liên hệ của bạn có thể chia sẻ một đường dẫn mời.]]> Nếu bạn xác nhận, các máy chủ truyền tin nhắn sẽ có thể biết địa chỉ IP, và nhà cung cấp của bạn - máy chủ nào mà bạn đang kết nối. Nếu bạn chọn từ chối người gửi sẽ KHÔNG được thông báo. - cho liên hệ của bạn xem mã QR trong cuộc gọi video, hoặc chia sẻ liên kết.]]> + cho liên hệ của bạn xem mã QR trong cuộc gọi video, hoặc chia sẻ đường dẫn.]]> Nhập Bỏ qua không hoạt động @@ -933,15 +933,15 @@ Nhập cơ sở dữ liệu Âm thanh trong cuộc gọi Nâng cao bảo mật và sự riêng tư - ẩn danh qua liên kết dùng một lần + ẩn danh qua đường dẫn dùng một lần Thông tin gián tiếp (%1$s) - Để tiếp tục, hãy ngắt kết nối tới các máy chủ dùng để truyền dẫn tin nhắn. + Để tiếp tục, hãy ngắt kết nối trò chuyện. Cài đặt SimpleX Chat cho cửa sổ câu lệnh - ẩn danh qua liên kết địa chỉ liên lạc + ẩn danh qua đường dẫn địa chỉ liên lạc Cuộc gọi thoại đến - Quyền hạn ban đầu - ẩn danh qua liên kết nhóm + Chức vụ ban đầu + ẩn danh qua đường dẫn nhóm Mã bảo mật không đúng! Trả lời đến Phiên bản cơ sở dữ liệu không tương thích @@ -955,16 +955,16 @@ Phiên bản không tương thích MÀU SẮC GIAO DIỆN đã được mời - Liên kết không hợp lệ - tác vụ trò chuyện không hợp lệ + Đường dẫn không hợp lệ + cuộc trò chuyện không hợp lệ dữ liệu không hợp lệ định dạng tin nhắn không hợp lệ Mời Lời mời đã hết hạn! - Liên kết không hợp lệ! - Liên kết không hợp lệ + Đường dẫn không hợp lệ! + Đường dẫn không hợp lệ Tên hiển thị không hợp lệ! - Liên kết kết nối không hợp lệ + Đường dẫn kết nối không hợp lệ Thông báo tức thời! Mã QR không hợp lệ Thông báo tức thời @@ -1023,7 +1023,7 @@ Chế độ khóa Giao diện tiếng Ý thiết bị này v%s)]]> - Biểu đạt cảm xúc tin nhắn bị cấm trong nhóm này. + Thả biểu tượng cảm xúc tin nhắn bị cấm. đã được mời để kết nối Tìm hiểu thêm Giữ @@ -1047,17 +1047,17 @@ Trung bình Tin nhắn động Giữ lại cuộc trò chuyện - Hình ảnh xem trước của liên kết + Hình ảnh xem trước của đường dẫn Thành viên sẽ bị xóa khỏi nhóm - việc này không thể được hoàn tác! Chế độ sáng UI tiếng Litva TIN NHẮN VÀ TỆP - Việc xóa tin nhắn mà không thể phục hồi bị cấm trong nhóm này. + Việc xóa tin nhắn mà không thể phục hồi là bị cấm. Tham gia vào các cuộc trò chuyện nhóm Chế độ định tuyến tin nhắn Hãy trò chuyện trên SimpleX Chat in nghiêng - Nó có thể được thay đổi sau trong phần cài đặt. + Nó ảnh hưởng đến pin như thế nào Việc xóa tin nhắn mà không thể phục hồi bị cấm trong cuộc trò chuyện này. Biểu đạt cảm xúc tin nhắn bị cấm trong cuộc trò chuyện này. Bản nháp tin nhắn @@ -1066,7 +1066,7 @@ Tin nhắn Lỗi keychain đã rời - đã được mời thông qua liên kết nhóm của bạn + đã được mời qua đường dẫn nhóm của bạn thành viên đã rời Nó cho phép việc có các kết nối ẩn danh mà không có bất kỳ dữ liệu chung nào giữa chúng trong một hồ sơ trò chuyện @@ -1081,7 +1081,7 @@ Xóa tin nhắn mà không thể phục hồi Tin nhắn Máy chủ tin nhắn - Máy chủ tệp và phương tiện + Máy chủ tệp và đa phương tiện Nội dung tin nhắn Tin nhắn đã bị xóa sau khi bạn chọn chúng. Tin nhắn từ %s sẽ được hiển thị! @@ -1144,10 +1144,10 @@ Cuộc trò chuyện mới phút Kho lưu trữ cơ sở dữ liệu mới - Quyền hạn thành viên mới + Chức vụ thành viên mới Nhiều cải tiến hơn nữa sắp ra mắt! %s có một phiên bản không được hỗ trợ. Xin vui lòng đảm bảo rằng bạn dùng cùng một phiên bản trên cả hai thiết bị.]]> - Các tùy chọn phương tiện mới + Các tùy chọn đa phương tiện mới Cuộc gọi nhỡ Nhiều cải tiến hơn nữa sắp ra mắt! Di chuyển sang một thiết bị khác qua mã QR. @@ -1178,13 +1178,13 @@ Không có gì để chuyển tiếp! Thông báo sẽ dừng hoạt động cho đến khi bạn khởi động lại ứng dụng Không có thông tin định danh người dùng. - không có nội dung + không có văn bản tắt tắt` Chỉ bạn mới có thể thực hiện cuộc gọi. Chỉ liên hệ của bạn mới có thể thả cảm xúc tin nhắn. Chỉ có thể gửi 10 video cùng một lúc - Liên kết lời mời dùng một lần + Đường dẫn lời mời dùng một lần (chỉ được lưu trữ bởi thành viên nhóm) Xem trước thông báo Dịch vụ thông báo @@ -1192,7 +1192,7 @@ Không có tệp nào được gửi hay được nhận quan sát viên Không có kết nối mạng - Chỉ chủ nhóm mới có thể bật tính năng cho phép gửi tệp và phương tiện. + Chỉ chủ nhóm mới có thể bật tính năng cho phép gửi tệp và đa phương tiện. Chỉ có thể gửi 10 hình ảnh cùng một lúc Chỉ một thiết bị mới có thể hoạt động cùng một lúc Không tương thích! @@ -1200,10 +1200,10 @@ OK Chỉ xóa cuộc trò chuyện Chỉ bạn mới có thể gửi tin nhắn thoại. - bảo mật đầu cuối 2 lớp.]]> + Chỉ thiết bị cuối mới lưu trữ các hồ sơ người dùng, liên hệ, nhóm, và tin nhắn Chỉ chủ nhóm mới có thể bật tính năng tin nhắn thoại. Chỉ bạn mới có thể gửi tin nhắn tự xóa. - Liên kết lời mời dùng một lần + Đường dẫn lời mời dùng một lần Chỉ có bạn mới có thể thả cảm xúc tin nhắn. Thông báo sẽ chỉ được gửi cho đến khi ứng dụng dừng! Chỉ bạn mới có thể xóa tin nhắn mà không thể phục hồi (liên hệ của bạn có thể đánh dấu chúng để xóa). (24 giờ) @@ -1212,12 +1212,12 @@ được đề nghị %s: %2s Tắt Chỉ chủ nhóm mới có thể điều chỉnh các tùy chọn nhóm. - Giờ thì quản trị viên có thể:\n- xóa tin nhắn của thành viên\n- vô hiệu hóa thành viên (quyền hạn quan sát viên) + Giờ thì quản trị viên có thể:\n- xóa tin nhắn của thành viên\n- vô hiệu hóa thành viên (chức vụ quan sát viên) Không có cuộc trò chuyện nào được chọn Không có gì được chọn Mở Mở bảng điều khiển trò chuyện - Mở hồ sơ trò chuyện + Thay đổi hồ sơ trò chuyện Dịch vụ onion sẽ được yêu cầu để kết nối.\nXin lưu ý: bạn sẽ không thể kết nối tới các máy chủ mà không có địa chỉ .onion. Mở Mở thư mục cơ sở dữ liệu @@ -1228,7 +1228,7 @@ Chỉ liên hệ của bạn mới có thể gửi tin nhắn thoại. chủ sở hữu Mở cài đặt máy chủ - Mở liên kết trong trình duyệt có thể làm giảm sự riêng tư và bảo mật của kết nối. Liên kết SimpleX không đáng tin cậy sẽ được đánh dấu màu đỏ. + Mở đường dẫn trong trình duyệt có thể làm giảm sự riêng tư và bảo mật của kết nối. Đường dẫn SimpleX không đáng tin cậy sẽ được đánh dấu màu đỏ. Chỉ liên hệ của bạn mới có thể xóa tin nhắn mà không thể phục hồi (bạn có thể đánh dấu chúng để xóa). (24 giờ) Mở cài đặt ứng dụng Mục mã truy cập @@ -1244,7 +1244,7 @@ Mở SimpleX Chat để chấp nhận cuộc gọi Mở Cài đặt Safari / Trang Web / Mic, rồi chọn Cho phép với localhost. Mã truy cập - Mở cuộc hội thoại + Mở cuộc trò chuyện Mở vị trí tệp Sử dụng từ máy tính trong ứng dụng di động và quét mã QR.]]> Hoặc dán đường dẫn lưu trữ @@ -1256,14 +1256,14 @@ các lỗi khác Các máy chủ SMP khác Các máy chủ XFTP khác - Dán đường dẫn mà bạn nhận được để kết nối với liên hệ hệ của bạn… + Dán đường dẫn mà bạn nhận được để kết nối với liên hệ của bạn… Dán đường dẫn Mật khẩu Định kỳ Đang chờ xử lý Đang chờ xử lý Không tìm thấy mật khẩu trong Keystore, vui lòng nhập thủ công. Điều này có thể xảy ra nếu bạn khôi phục dữ liệu ứng dụng bằng một công cụ sao lưu. Nếu không phải như vậy, xin vui lòng liên hệ với nhà phát triển. - Thành viên cũ %1$s + Thành viên trước đây %1$s Dán đường dẫn để kết nối! Thông báo định kỳ Dán đường dẫn mà bạn nhận được @@ -1285,7 +1285,7 @@ Xin vui lòng nhập đúng mật khẩu hiện tại. Mở từ danh sách cuộc trò chuyện. Cuộc gọi hình trong hình - Xin vui lòng báo cáo với các nhà phát triển:\n%s + Xin vui lòng báo cáo với các nhà phát triển: \n%s Thông báo định kỳ đã bị tắt! Xin vui lòng kiểm tra rằng thiết bị di động và máy tính kết nối tới cùng một mạng cục bộ, và tường lửa của máy tính cho phép kết nối.\nHãy chia sẻ bất kỳ vấn đề nào khác với nhà phát triển. Xin vui lòng kiểm tra rằng bạn đã dùng đúng đường dẫn hoặc yêu cầu liên hệ của bạn gửi cho bạn một đường dẫn khác. @@ -1296,12 +1296,12 @@ Xin vui lòng nhập mật khẩu trước đó sau khi khôi phục bản sao lưu cơ sở dữ liệu. Việc này không thể được hoàn tác. Xin vui lòng liên lạc với quản trị viên nhóm. Xin vui lòng báo cáo với các nhà phát triển. - Xin vui lòng báo cáo tới các nhà phát triển:\n%s\n\nGợi ý rằng bạn nên khởi động lại ứng dụng. + Xin vui lòng báo cáo với các nhà phát triển: \n%s\n\nGợi ý rằng bạn nên khởi động lại ứng dụng. Có lẽ vân tay chứng chỉ trong địa chỉ máy chủ là không chính xác Đang chuẩn bị tải lên Cổng cổng %d - Xin vui lòng lưu trữ mật khẩu một cách an toàn, bạn sẽ KHÔNG thể trò chuyện nếu bạn làm mất nó. + Xin vui lòng lưu trữ mật khẩu một cách an toàn, bạn sẽ KHÔNG thể try cập kết nối trò chuyện nếu bạn làm mất nó. Xin vui lòng chờ trong khi tệp đang được tải từ thiết bị được liên kết Địa chỉ máy chủ cài sẵn Lưu lại bản nháp tin nhắn cuối cùng, với các tệp đính kèm. @@ -1345,12 +1345,12 @@ Thời gian chờ giao thức Cấm gửi tin nhắn tự xóa. Bảo vệ màn hình ứng dụng - Cấm gửi tệp và phương tiện truyền thông. + Cấm gửi tệp và đa phương tiện. Cấm gửi tin nhắn thoại. Cấm gửi tin nhắn tự xóa. Cấm gửi đường dẫn SimpleX Được proxy - Địa chỉ hay đường dẫn dùng một lần? + Địa chỉ hay đường dẫn dùng 1 lần? với chỉ một liên hệ - chia sẻ trực tiếp hoặc thông qua bất kỳ ứng dụng tin nhắn nào.]]> Cài đặt máy chủ .onion thành Không nếu proxy SOCKS không hỗ trợ chúng.]]> %s.]]> @@ -1360,7 +1360,7 @@ Thanh công cụ ứng dụng Làm mờ Chấp nhận điều kiện - Đã thêm các máy chủ truyền tệp & phương tiện + Đã thêm các máy chủ truyền tệp & đa phương tiện Đã chấp nhận điều kiện Cài đặt địa chỉ %s.]]> @@ -1373,15 +1373,15 @@ %1$s rồi.]]> Mức sử dụng pin ứng dụng / Không hạn chế trong phần cài đặt ứng dụng.]]> %s]]> - cho phép SimpleX chạy trong nền trong hộp thoại tiếp theo. Nếu không, thông báo sẽ bị vô hiệu hóa.]]> + Chọn cho phép trong hộp thoại tiếp theo để nhận thông báo ngay lập tức.]]> %s, vui lòng chấp nhận điều kiện sử dụng.]]> - Dịch vụ nền SimpleX – nó tiêu tốn một vài phần trăm pin mỗi ngày.]]> + SimpleX chạy trong nền thay vì dùng thông báo đẩy.]]> Mức sử dụng pin ứng dụng / Không hạn chế trong phần cài đặt ứng dụng.]]> %1$s rồi.]]> %1$s!]]> %1$s rồi.]]> Mở trong ứng dụng di động.]]> - Chọn nhà cung cấp + Các bên vận hành máy chủ kết nối với các nhà phát triển SimpleX Chat để hỏi bất kỳ câu hỏi nào và nhận thông tin cập nhật.]]> không được sử dụng cùng một cơ sở dữ liệu trên hai thiết .]]> Lỗi chấp nhận điều kiện @@ -1393,10 +1393,973 @@ Tiếp tục Không thể tải văn bản về các điều kiện hiện tại, bạn có thể xem xét các điều kiện thông qua đường dẫn này: Các điều kiện sử dụng - Cho phép flux + Sử dụng Flux trong cài đặt Mạng & máy chủ để bảo mật siêu dữ liệu tốt hơn. Các điều kiện sẽ được chấp nhận vào: %s. Lỗi thêm máy chủ Lỗi cập nhật máy chủ Các điều kiện đã được chấp nhận vào: %s. Các điều kiện sẽ được tự động chấp nhận với các nhà cung cấp được cho phép vào: %s. - \ No newline at end of file + Thiết bị Xiaomi: vui lòng bật Tự động khởi động trong phần cài đặt hệ thống để thông báo có thể hoạt động bình thường.]]> + Ứng dụng luôn chạy dưới nền + Cuộc trò chuyện sẽ bị xóa cho tất cả các thành viên - việc này không thể được hoàn tác! + Cuộc trò chuyện sẽ bị xóa cho bạn - việc này không thể được hoàn tác! + Các cuộc trò chuyện công việc + Thêm các thành viên nhóm + Cuộc trò chuyện + %1$s rồi.]]> + Cuộc trò chuyện đã tồn tại! + Thêm bạn bè + đã chấp nhận lời mời + mã hóa đầu cuối, với bảo mật sau ượng tử trong các tin nhắn trực tiếp.]]> + Giới thiệu về các nhà cung cấp + Thêm các thành viên nhóm của bạn vào các cuộc trò chuyện. + Địa chỉ doanh nghiệp + Lỗi lưu cơ sở dữ liệu + Tin nhắn trực tiếp giữa các thành viên bị cấm trong cuộc trò chuyện này. + Dành cho hồ sơ trò chuyện %s: + Kiểm tra tin nhắn mỗi 10 phút. + Ví dụ, nếu liên hệ của bạn nhận tin nhắn thông qua một máy chủ SimpleX Chat, ứng dụng của bạn sẽ gửi chúng qua một máy chủ Flux. + Cách nó cải thiện sự riêng tư + Mời để trò chuyện + Rời cuộc trò chuyện + Cho định tuyến riêng tư + để bảo mật siêu dữ liệu tốt hơn. + Đã cải thiện điều hướng trò chuyện + Xóa cuộc trò chuyện + Cho mạng xã hội + Rời cuộc trò chuyện? + Kết nối yêu cầu thiết lập lại mã hóa. + Đang trong quá trình thiết lập lại mã hóa. + Sửa + Sửa kết nối? + Xóa cuộc trò chuyện? + Tin nhắn trực tiếp giữa các thành viên bị cấm. + Bật logs + a + b + Kết nối chưa sẵn sàng. + Lỗi cập nhật danh sách trò chuyện + Lỗi tạo danh sách trò chuyện + Lỗi tải các danh sách trò chuyện + Các liên hệ + Nhóm + Thêm danh sách + Tất cả + Xóa + Xóa danh sách? + Chỉnh sửa + Tên danh sách... + Xóa hồ sơ trò chuyện cho + Tạo danh sách + Thêm vào danh sách + Các doanh nghiệp + Tất cả các cuộc trò chuyện sẽ bị xóa khỏi danh sách %s, và danh sách cũng sẽ bị xóa + Ưa thích + Danh sách + Xóa tin nhắn sau + Không có máy chủ tin nhắn. + Không có máy chủ để định tuyến tin nhắn riêng tư. + Không có máy chủ để nhận tin nhắn. + Tin nhắn quá lớn! + Các nhà cung cấp mạng + Thông báo và pin + Chỉ có các chủ cuộc trò chuyện mới có thể thay đổi các tùy chọn. + Thành viên sẽ bị xóa khỏi cuộc trò chuyện - việc này không thể được hoàn tác! + Nhà cung cấp mạng + Phi tập trung hóa mạng lưới + Không có tin nhắn + Không có cuộc trò chuyện nào + Không tìm thấy cuộc trò chuyện nào + Không có cuộc trò chuyện nào trong dánh sách %s. + Không có cuộc trò chuyện nào chưa được đọc + Tên danh sách và biểu tượng cảm xúc phải khác nhau đối với tất cả các danh sách. + Không có máy chủ để nhận tệp. + Không có máy chủ để gửi tệp. + Không có dịch vụ nền + Máy chủ mới + Không có máy chủ tệp và đa phương tiện. + Thanh công cụ trò chuyện trong tầm + Ngẫu nhiên + Các máy chủ cài sẵn + Xem thay đổi + Xem điều kiện + Bên vận hành máy chủ + - Mở cuộc trò chuyện từ tin nhắn chưa đọc đầu tiên.\n- Nhảy tới tin nhắn được trích dẫn. + Hoặc nhập tệp lưu trữ + Mã hóa kháng lượng tử + Sự riêng tư cho các khách hàng của bạn. + Xin vui lòng giảm kích thước tin nhắn và gửi lại. + Xin vui lòng giảm kích thước tin nhắn hoặc xóa đa phương tiện và gửi lại. + Xác thực proxy + Mã QR + Mở bằng %s + Thanh công cụ trò chuyện trong tầm + Đánh giá ứng dụng + Mật khẩu ngẫu nhiên được lưu trong cài đặt dưới dạng văn bản thuần túy.\nBạn có thể thay đổi nó sau. + Các máy chủ được proxy + Đọc thêm + Thanh công cụ ứng dụng trong tầm + Hoặc chia sẻ một cách riêng tư + Bên vận hành + mã hóa đầu cuối kháng lượng tử + đã nhận lời đáp… + Đã nhận tin nhắn + Đang nhận qua + đã nhận, bị cấm + Lịch sử gần đây và đã cải thiện bot thư mục. + Việc nhận tệp vẫn chưa được hỗ trợ + Đã nhận lời đáp + Địa chỉ nhận sẽ được đổi sang một máy chủ khác. Việc thay đổi địa chỉ sẽ hoàn thành sau khi người gửi hoạt động. + đã nhận lời xác nhận… + Kết nối lại tất cả các máy chủ đã được kết nối để buộc gửi tin nhắn. Việc này tiêu tốn thêm lưu lượng. + Kết nối lại tất cả các máy chủ + Đã nhận tổng số + Nhận được lỗi + Người nhận có thể thấy các bản cập nhật tin nhắn khi bạn gõ chúng. + Kết nối lại máy chủ để buộc gửi tin nhắn. Việc này tiêu tốn thêm lưu lượng. + Đã nhận tin nhắn + Việc nhận tệp sẽ bị dừng lại + Đang nhận tin nhắn… + Đã nhận tin nhắn + Kết nối lại máy chủ? + Chỉ báo đã nhận bị tắt + Kết nối lại các máy chủ? + Người nhận không thể xem tin nhắn này đến từ ai. + Kết nối lại + Đang nhận đồng thời + Đã nhận vào + Đã nhận vào: %s + Xóa ảnh + Đã từ chối cuộc gọi + Nhắc lại sau + Làm mới + Đã giảm mức sử dụng pin + Từ chối + Các thiết bị di động từ xa + Xóa + Xóa + Xóa thành viên + Xóa thành viên + Xóa thành viên? + Từ chối + đã xóa + Bản ghi được cập nhật vào + Ghi lại tin nhắn thoại + Xóa mật khẩu khỏi cài đặt? + Xóa mật khẩu khỏi Keystore? + Bản ghi được cập nhật vào: %s + Xóa kho lưu trữ? + đã xóa địa chỉ liên lạc + đã xóa ảnh đại diện hồ sơ + Máy chủ relay chỉ được sử dụng khi cần thiết. Bên khác có thể quan sát địa chỉ IP của bạn. + đã xóa %1$s + đã xóa bạn + đã từ chối cuộc gọi + Máy chủ relay bảo vệ địa chỉ IP của bạn, nhưng nó có thể quan sát thời lượng của cuộc gọi. + Đặt lại các màu + Lặp lại yêu cầu kết nối? + Đặt lại màu + Đặt lại về mặc định + Đặt lại + Lặp lại + đã yêu cầu kết nối + Tải lên lại lần nữa + Thiết lập lại mã hóa? + Nhập lại + Bắt buộc + Tải xuống lại lần nữa + Thiết lập lại + Đặt lại + Trả lời + Đặt lại về chủ đề người dùng + Thiết lập lại mã hóa + Khởi động lại + Khởi động lại ứng dụng để sử dụng cơ sở dữ liệu trò chuyện đã được nhập. + Lặp lại yêu cầu tham gia? + Đặt lại tất cả gợi ý + Đặt lại trở về chủ đề ứng dụng + Khởi động lại ứng dụng để tạo một hồ sơ trò chuyện mới. + Đặt lại tất cả số liệu thống kê + Đặt lại tất cả số liệu thống kê? + Lưu mật khẩu và mở kết nối trò chuyện + Gửi + KHỞI CHẠY KẾT NỐI TRÒ CHUYỆN + Lưu + Quét / Dán đường dẫn + Quét mã QR máy chủ + Lỗi khôi phục cơ sở dữ liệu + %s (hiện tại) + %s vào %s + Chọn các liên hệ + bí mật + gửi tệp chưa được hỗ trợ + Gửi tin nhắn tự xóa + Các tùy chọn của cuộc trò chuyện được chọn không cho phép tin nhắn này. + Quét mã QR + đã lưu + đã lưu từ %s + Đã lưu từ + Quét mã QR từ máy tính + Đã được bảo mật + Thanh tìm kiếm chấp nhận đường dẫn lời mời. + Rào hàng đợi + Gửi lỗi + Lưu + Chọn + Mã truy cập tự hủy + Khôi phục + %s và %s đã được kết nối + %s đã được tải xuống + Khôi phục bản sao lưu cơ sở dữ liệu + Chạy khi sử dụng ứng dụng + Thu hồi tệp + Thu hồi tệp? + Thu hồi + gửi tin nhắn trực tiếp + Đã chọn %d + Gửi tin nhắn trực tiếp để kết nối + Đang lưu %1$s tin nhắn + (quét hoặc dán từ bảng nháp) + Chọn các bên vận hành mạng lưới để sử dụng. + Gửi tin nhắn trực tiếp + Lưu lời chào? + gửi thất bại + Quét mã bảo mật từ ứng dụng của liên hệ bạn. + GỬI CHỈ BÁO ĐÃ NHẬN TỚI + Tìm kiếm + Tìm kiếm hoặc dán đường dẫn SimpleX + Lưu danh sách + Thử lại + Lưu và thông báo tới liên hệ + Xem xét lại sau + mã bảo mật đã thay đổi + Lưu hồ sơ nhóm + Lưu và kết nối lại + Tính năng gửi chỉ báo đã nhận sẽ được bật cho tất cả liên hệ. + Xem xét lại các điều kiện + Tỷ lệ + Gửi + Chọn hồ sơ trò chuyện + Quét mã + Mã bảo mật + Lưu các máy chủ? + %s đã được kết nối + Đã lưu tin nhắn + giây + Gửi một tin nhắn động - nó sẽ cập nhật cho (các) người nhận ngay khi bạn gõ + Người gửi đã hủy quá trình truyền tệp. + Mã truy cập tự hủy + Các máy chủ WebRTC ICE đã được lưu sẽ bị xóa. + Khởi động lại kết nối trò chuyện + Lưu + Tính năng gửi chỉ báo đã nhận sẽ được bật cho tất cả các liên hệ trong tất cả các hồ sơ trò chuyện có thể thấy được. + Tiết lộ + Lưu và thông báo tới các liên hệ + Lưu vào thông báo tới các thành viên nhóm + Lưu mật khẩu hồ sơ + Thẩm định bảo mật + Nhận tệp một cách an toàn + Lưu và cập nhật hồ sơ nhóm + giây + Đã lưu + Tự hủy + Chọn + Mã truy cập tự hủy đã được kích hoạt! + Lưu mật khẩu trong Keystore + %s và %s + Chức vụ + Các nhóm trở nên an toàn hơn + Quét từ di động + Ghi chú + tìm kiếm + Thay đổi danh sách + Thay đổi thứ tự + Lưu các máy chủ + Lưu cài đặt tự động chấp nhận + Lưu tùy chọn? + Lưu cài đặt? + Khôi phục bản sao lưu cơ sở dữ liệu? + Lưu mật khẩu trong cài đặt + Mã truy cập tự hủy đã được đổi! + Người gửi có thể đã xóa yêu cầu kết nối. + Phiên bản máy chủ không tương thích với ứng dụng của bạn: %1$s. + Đặt 1 ngày + đặt ảnh đại diện mới + Các tin nhắn đã gửi sẽ bị xóa sau thời gian đã cài. + thông tin hàng đợi máy chủ: %1$s\n\ntin nhắn được nhận cuối cùng: %2$s + CÀI ĐẶT + Đã gửi vào + Địa chỉ máy chủ + Mã phiên + Lời đáp đã gửi + Đặt tên liên hệ… + Gửi tin nhắn động + Đặt tên liên hệ + Bên vận hành máy chủ đã được đổi. + Giao thức máy chủ đã được đổi. + Gửi tin nhắn trực tiếp khi địa chỉ của bạn hoặc địa chỉ đích không hỗ trợ định tuyến riêng tư. + Gửi xem trước đường dẫn + Các tin nhắn đã gửi + Thông tin các máy chủ + Máy chủ yêu cầu xác thực để tạo hàng đợi, kiểm tra mật khẩu + Việc gửi tệp sẽ bị dừng lại. + Gửi thư điện tử cho chúng tôi + Đã gửi vào: %s + Máy chủ đã được thêm cho bên vận hành %s. + Địa chỉ máy chủ không tương thích với cài đặt mạng. + Phiên bản máy chủ không tương thích với cài đặt mạng. + đã gửi + Cài đặt + Gửi các câu hỏi và ý tưởng + Đặt nó thay vì sử dụng xác thực hệ thống. + Đã gửi tin nhắn + Đặt mật khẩu để xuất + Đang gửi qua + Đặt mật khẩu + Cài đặt + Địa chỉ máy chủ không tương thích với cài đặt mạng: %1$s. + CÁC MÁY CHỦ + Máy chủ yêu cầu xác thực để tải lên, kiểm tra mật khẩu + Máy chủ + Đặt mã truy cập + Gửi chỉ báo đã nhận bị tắt cho %d liên hệ + Đặt tùy chọn nhóm + Số liệu thống kê các máy chủ sẽ được đặt lại - việc này không thể được hoàn tác! + Gửi tin nhắn để cho phép gọi điện. + Đã gửi qua proxy + Gửi chỉ báo đã nhận được bật cho %d liên hệ + Đặt mật khẩu cơ sở dữ liệu + Gửi chỉ báo đã nhận + đặt địa chỉ liên lạc mới + Tin nhắn đã gửi + Đặt chủ đề mặc định + Gửi tối đa 100 tin nhắn cuối cùng tới các thành viên mới. + Tổng số đã gửi + Gửi Tin nhắn + Kiểm tra máy chủ thất bại! + Gửi tin nhắn trực tiếp khi địa chỉ IP được bảo vệ và máy chủ của bạn hoặc máy chủ đích không hõ trợ định tuyến riêng tư. + Gửi chỉ báo đã nhận bị tắt cho %d nhóm + Gửi chỉ báo đã nhận được bật cho %d nhóm + Đã gửi trực tiếp + Hiển thị thông điệp tới các thành viên mới! + Chia sẻ địa chỉ SimpleX trên mạng xã hội. + Hiển thị liên hệ và tin nhắn + Hiển thị trạng thái tin nhắn + Hiển thị: + Chia sẻ địa chỉ một cách công khai + Hiển thị + Hiển thị các tin nhắn cuối cùng + Chia sẻ + Hiển thị bảng điều khiển trong cửa sổ mới + Lỗi tạo báo cáo + Chia sẻ tin nhắn… + Hiển thị danh sách trò chuyện trong cửa sổ mới + Chỉ hiển thị liên hệ + Chia sẻ đường dẫn dùng 1 lần này + Chia sẻ với các liên hệ + Chia sẻ tệp… + Chia sẻ địa chỉ + Chia sẻ đa phương tiện… + Chia sẻ hồ sơ + Hiển thị tùy chọn cho nhà phát triển + Tạo khuôn ảnh đại diện + Thiết lập mật khẩu cơ sở dữ liệu + Đang hiển thị thông tin cho + Chia sẻ đường dẫn dùng 1 lần + Chia sẻ địa chỉ với các liên hệ? + Hiển thị tỷ lệ phần trăm + Hiển thị lỗi nội bộ + Chia sẻ đường dẫn + Chia sẻ đường dẫn dùng 1 lần với một người bạn + Lỗi lưu cài đặt + Hiển thị bản xem trước + Các cuộc gọi SimpleX Chat + simplexmq: v%s (%2s) + Các đường dẫn SimpleX là không được phép + Khóa SimpleX + Các đường dẫn SimpleX + Các tin nhắn SimpleX Chat + Địa chỉ SimpleX + SimpleX không thể chạy trong nền. Bạn sẽ chỉ nhận được thông báo khi ứng dụng đang chạy. + Chế độ Khóa SimpleX + Địa chỉ liên lạc SimpleX Chat + Đường dẫn nhóm SimpleX + Các đường dẫn SimpleX + Lời mời SimpleX dùng một lần + Khóa SimpleX + SimpleX Chat và Flux đã đi đến một thỏa thuận để đưa các máy chủ do Flux vận hành vào ứng dụng. + SimpleX + Địa chỉ SimpleX + Tắt? + Tắt + Sự an toàn của SimpleX Chat đã được kiểm định bởi Trail of Bits. + Dịch vụ SimpleX Chat + Khóa SimpleX đã được bật + Hiển thị mã QR + Địa chỉ SimpleX và các đường dẫn dùng một lần đều an toàn để chia sẻ thông qua bất kỳ ứng dụng nhắn tin nào. + Logo SimpleX + Hiển thị các lượt yêu cầu API chậm + Địa chỉ SimpleX hay đường dẫn dùng 1 lần? + Khóa SimpleX không được bật! + Các đường dẫn SimpleX là bị cấm. + Chỉ người gửi và các kiểm duyệt mới xem được + Lưu trữ bản báo cáo? + đã lưu trữ bản báo cáo + Báo cáo nội dụng: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Lưu trữ + Một lý do khác + kiểm duyệt viên + Báo cáo hồ sơ thành viên: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Đội ngũ SimpleX + Các giao thức SimpleX đã được xem xét bởi Trail of Bits. + Đã đơn giản hóa chế độ ẩn danh + Vi phạm các nguyên tắc cộng động + Nội dung không phù hợp + Hồ sơ không phù hợp + Chỉ bạn và các kiểm duyệt viên mới xem được + Báo cáo + Lý do báo cáo? + Báo cáo khác: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Báo cáo tin nhắn rác: chỉ có các kiểm duyệt viên trong nhóm là xem được. + Báo cáo vi phạm: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Kích thước + %s đã được xác minh + %s chưa được xác minh + Các tin nhắn bị bỏ qua + Bỏ qua việc mời các thành viên + Loa ngoài bật + Âm thanh đã bị tắt + Ổn định + PROXY SOCKS + Các nhóm nhỏ (tối đa 20 thành viên) + Một vài lỗi không nghiêm trọng đã xảy ra trong lúc nhập: + Loa ngoài tắt + Lưu trữ báo cáo + Xóa báo cáo + Các máy chủ %s + Hàm chạy chậm + Nhẹ + %s giây + Một vài máy chủ không vượt qua bài kiểm tra: + Thả sao trên Github + %s, %s và %s đã được kết nối + Máy chủ SMP + %s, %s và %d thành viên khác đã được kết nối + mã hóa đầu cuối tiêu chuẩn + Hình vuông, hình tròn, hoặc bất kỳ hình thù gì ở giữa. + proxy SOCKS + Cài đặt proxy SOCKS + đang bắt đầu… + %s, %s và %d thành viên + %s: %s + Bỏ qua phiên bản này + Một số tệp đã không được xuất + Loa ngoài + Bắt đầu kết nối trò chuyện? + Các máy chủ SMP + Bắt đầu kết nối trò chuyện + Tin nhắn rác + Mạnh + Bắt đầu từ %s. + Dừng + Dừng kết nối trò chuyện? + %s đã được tải lên + Đã đăng ký + đã lưu trữ báo cáo bởi %s + Dừng tệp + 1 báo cáo + %d báo cáo + Các báo cáo của thành viên + Lỗi đăng ký dài hạn + Các báo cáo + Bắt đầu từ %s.\nTất cả dữ liệu được lưu trữ một cách riêng tư trên thiết bị của bạn. + Hỗ trợ bluetooth và nhiều cải tiến khác. + Các đăng ký dài hạn bị bỏ qua + Dừng nhận tệp? + Dừng gửi tệp? + Tạo cuộc trò chuyện mới + Số liệu thống kê + Bắt đầu định kỳ + Dừng + Dừng chia sẻ địa chỉ? + Dừng kết nối trò chuyện để xuất, nhập hoặc xóa cơ sở dữ liệu trò chuyện. Bạn sẽ không thể nhận hay gửi tin nhắn trong khi kết nối trò chuyện bị dừng lại. + Nhập + Đang dừng kết nối trò chuyện + Dừng chia sẻ + gạch ngang + Dừng kết nối trò chuyện + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Hệ thống + Nhấn để tham gia + Thời gian chờ kết nối TCP + Nhấn để kích hoạt hồ sơ. + Hệ thống + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Chế độ hệ thống + HỖ TRỢ SIMPLEX CHAT + Lỗi tệp tạm thời + Nhấn nút + Kết nối TCP + Hệ thống + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Nhấn để Kết nối + Nhấn Tạo địa chỉ SimpleX ở menu để tạo sau. + Nhấn để tham gia một cách ẩn danh + Hệ thống + Chuyển đổi âm thanh và video trong lúc gọi. + Chuyển đổi hồ sơ trò chuyện cho các lời mời dùng 1 lần. + Kiểm tra thất bại tại bước %s. + Xác thực hệ thống + Nhấn để quét + Cảm ơn bạn đã cài đặt SimpleX Chat! + Kiểm tra máy chủ + Kiểm tra các máy chủ + Nhấn để bắt đầu một cuộc trò chuyện mới + Nhấn để dán đường dẫn + Đuôi + Chuyển đổi + Kết nối đã chạm giới hạn của các tin nhắn chưa được gửi đi, liên hệ của bạn có thể đang ngoại tuyến. + Tương lai của nhắn tin + Hình ảnh không thể được giải mã. Xin vui lòng thử lại với một hình ảnh khác hoặc liên lạc với các nhà phát triển. + Các tin nhắn sẽ bị xóa cho tất cả các thành viên. + Mã hóa đang hoạt động và thỏa thuận mã hóa mới là không bắt buộc. Nó có thể dẫn đến các lỗi kết nối! + Ứng dụng sẽ yêu cầu bạn xác nhận các lượt tải xuống từ các máy chủ truyền tệp không xác định (ngoại trừ .onion hoặc khi proxy SOCKS được sử dụng). + Kết nối đã bị chặn + Kết nối đã bị chặn bởi bên vận hành máy chủ:\n%1$s. + Nội dung vi phạm các điều kiện sử dụng + Tin nhắn rác + Tệp đã bị chặn bởi bên vận hành máy chủ:\n%1$s. + Cơ sở dữ liệu đang không hoạt động như bình thường. Nhấn để tìm hiểu thêm + Không + Mở đường dẫn + Yêu cầu + Mở đường dẫn web? + Mở các đường dẫn từ danh sách trò chuyện + Kết nối bạn đã chấp nhận sẽ bị hủy bỏ! + Liên hệ mà bạn đã chia sẻ đường dẫn này sẽ không thể kết nối! + Trình duyệt web mặc định là cần thiết cho các cuộc gọi. Xin vui lòng thiết lập trình duyệt mặc định trong hệ thống, và chia sẻ thêm thông tin với các nhà phát triển. + Ứng dụng bảo vệ sự riêng tư của bạn bằng cách sử dụng các bên vận hành khác nhau trong mỗi cuộc trò chuyện. + Ứng dụng có thể bị đóng sau 1 phút chạy trong nền. + Ứng dụng tìm nhận tin nhắn mới một cách định kỳ - nó tiêu tốn một vài phần trăm pin mỗi ngày. Ứng dụng không sử dụng thông báo đẩy - dữ liệu ở thiết bị của bạn không được gửi đi tới máy chủ nào. + Mã mà bạn đã quét không phải là một mã QR dẫn SimpleX. + Chủ đề + Mã băm của tin nhắn trước có sự khác biệt.\" + ID của tin nhắn tiếp theo là không chính xác (nhỏ hơn hoặc bằng với cái trước).\nViệc này có thể xảy ra do một vài lỗi hoặc khi kết nối bị xâm phạm. + Nỗ lực đổi mật khẩu cơ sở dữ liệu đã không được hoàn thành. + Tên thiết bị sẽ được chia sẻ với thiết bị di động đã được kết nối. + CÁC CHỦ ĐỀ + Tên hiển thị này không hợp lệ. Xin vui lòng chọn một cái tên khác. + Hồ sơ chỉ được chia sẻ với các liên hệ của bạn. + Cuộc trò chuyện này được bảo vệ bằng mã hóa đầu cuối có kháng lượng tử. + Mật khẩu được lưu trữ trong cài đặt dưới dạng thuần văn bản. + Thiết bị này + Video không thể được giải mã. Xin vui lòng thử với một video khác hoặc liên lạc với các nhà phát triển. + Tin nhắn sẽ bị đánh dấu là đã được kiểm duyệt cho tất cả các thành viên. + Tin nhắn sẽ bị xóa cho tất cả các thành viên. + Việc này không thể được hoàn tác - tất cả các đa phương tiện và tệp đã được gửi và nhận sẽ bị xóa. Những hình ảnh chất lượng thấp sẽ được giữ lại. + Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận sớm hơn so với thời gian được chọn sẽ bị xóa. Có thể mất vài phút để hoàn thành. + Nhóm này không còn tồn tại. + Nhóm này có trên %1$d thành viên, chỉ báo đã nhận không được gửi. + Các tin nhắn sẽ bị đánh dấu là đã được kiểm duyệt cho tất cả thành viên. + Tên thiết bị này + Chúng có thể bị ghi đề trong cài đặt liên hệ và nhóm. + Cuộc trò chuyện này được bởi vệ bằng mã hóa đầu cuối. + Tính năng này chưa được hỗ trợ. Hãy thử bản phát hành tiếp theo. + Dấu tick thứ hai mà chúng ta từng thiếu! ✅ + Những máy chủ cho tệp mới của hồ sơ trò chuyện hiện tại của bạn + Nền tảng ứng dụng và nhắn tin bảo vệ sự riêng tư và bảo mật của bạn. + Bên vận hành được cài sẵn thứ hai trong ứng dụng! + Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong cuộc trò chuyện sẽ được thông báo. + Văn bản bạn vừa dán không phải là một đường dẫn SimpleX. + Chức vụ sẽ được đổi thành %s. Thành viên sẽ nhận được một lời mời mới. + Mật khẩu sẽ được lưu trữ trong cài đặt dưới dạng thuần văn bản sau khi bản đổi nó hoặc khởi động lại ứng dụng. + Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn + Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong nhóm sẽ được thông báo. + Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. + Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. + Những máy chủ cho các kết nối mới của hồ sơ trò chuyện hiện tại của bạn + Báo cáo sẽ được lưu trữ cho bạn. + Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. + Mã QR này không phải là một đường dẫn! + Đường dẫn này không phải là một đường dẫn kết nối hợp lệ! + Chuyển đổi danh sách trò chuyện: + Thời gian chờ đã hết trong khi kết nối tới máy tính + Để cho phép một ứng dụng di động kết nối tới máy tính, mở cổng này trong tường lửa của bạn, nếu bạn có bật nó lên + Để bảo vệ sự riêng tư của bạn, SimpleX sử dụng các ID riêng biệt cho mỗi liên hệ bạn có. + Để nhận thông báo, xin vui lòng nhập mật khẩu cơ sở dữ liệu + Quá nhiều ảnh! + Quá nhiều video! + Để gửi + Đường dẫn này đã được sử dụng với một thiết bị di động khác, xin vui lòng tạo một đường dẫn mới trên máy tính. + Để bảo vệ thông tin của bạn, bật Khóa SimpleX.\nBạn sẽ được nhắc để hoàn thành xác thực trước khi tính năng này được bật. + Chuỗi ký tự này không phải là một đường dẫn kết nối! + Để thực hiện các cuộc gọi, cho phép sử dụng mic của bạn. Kết thúc cuộc gọi và thử gọi lại. + Để nhận + Để kết nối, liên hệ của bạn có thể quét mã QR hoặc dùng đường dẫn trong ứng dụng. + Tiêu đề + Chuyển đổi ẩn danh khi kết nối. + Để bảo vệ múi giờ, các tệp hình ảnh/âm thanh sử dụng UTC. + Văn bản này có sẵn trong cài đặt + Để được thông báo về các bản phát hành mới, bật kiểm tra định kỳ cho các phiên bản Ổn định hoặc Beta. + Để ẩn các tin nhắn không mong muốn. + Để tiết lộ hồ sơ ẩn của bạn, nhập đầy đủ mật khẩu vào trường tìm kiếm trong trang Các hồ sơ trò chuyện của bạn. + Đây là đường dẫn dùng một lần của riêng bạn! + Cài đặt này áp dụng cho các tin nhắn trong hồ sơ trò chuyện hiện tại của bạn + Đây là địa chỉ SimpleX của riêng bạn! + Để kết nối qua đường dẫn + Để bảo vệ đường dẫn của bạn khỏi bị thay thế, bạn có thể so sánh các mã bảo mật liên lạc. + Để bảo vệ địa chỉ IP của bạn, định tuyến riêng tư sử dụng các máy chủ SMP của bạn để gửi tin nhắn. + Cách ly truyền tải + Các máy chủ không xác định + trạng thái không xác định + Lỗi không xác định + Các tin nhắn không được gửi đi + Bỏ yêu thích + Bỏ chặn cho tất cả + Bỏ chặn thành viên cho tất cả? + Để bắt đầu một cuộc trò chuyện mới + Bỏ ẩn hồ sơ trò chuyện + Bỏ ẩn hồ sơ + đã bỏ chặn %s + Bỏ chặn + Bỏ chặn thành viên + Bỏ chặn thành viên? + Độ trong suốt + Các phiên truyền tải + Các máy chủ không xác định! + gửi mà không được cho phép + (để chia sẻ với liên hệ của bạn) + Bỏ ẩn + Bật + Tổng + không xác định + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. + Để xác minh mã hóa đầu cuối với liên hệ của bạn, so sánh (hoặc quét) mã trên các thiết bị của các bạn. + Cách ly truyền tải + định dạng tin nhắn không xác định + Lỗi cơ sở dữ liệu không xác định: %s + Tối đa tới 100 tin nhắn cuối cùng là được gửi tới các thành viên mới. + Sử dụng các thông tin đăng nhập proxy khác cho mỗi kết nối. + Sử dụng các thông tin đăng nhập proxy khác nhau cho mỗi hồ sơ. + Cập nhật chế độ cách ly truyền tải? + Hủy liên kết máy tính? + Tải lên tệp + Có bản cập nhật: %s + Việc cập nhật cài đặt sẽ kết nối lại thiết bị với tất cả các máy chủ. + Đã tải lên các tệp + chưa đọc + Cập nhật + Cập nhật mật khẩu cơ sở dữ liệu + Hủy liên kết + Cập nhật cài đặt mạng? + Tải lên đã thất bại + Đang tải lên bản lưu trữ + Tải xuống bản cập nhật đã bị hủy + Bật thông báo + Nâng cấp và mở kết nối trò chuyện + Bật thông báo + đã cập nhật hồ sơ nhóm + đã cập nhật hồ sơ + Sử dụng hồ sơ hiện tại + Sử dụng kết nối Internet trực tiếp? + Không được bảo vệ + Trừ khi liên hệ của bạn đã xóa kết nối hoặc đường dẫn này đã được sử dụng, có thể đã có lỗi - xin vui lòng báo cáo.\nĐể kết nối, xin vui lòng yêu cầu liên hệ của bạn tạo một đường dẫn kết nối khác và chắc chắn rằng bạn có kết nối mạng ổn định. + Lỗi tải lên + Nâng cấp ứng dụng một cách tự động + Đã tải lên + Cập nhật + Cập nhật + Mở khóa + Xác minh bảo mật kết nối + Sử dụng cho các tệp + Dùng ứng dụng với một tay. + Xác minh mã trên di động + Xác minh mật khẩu cơ sở dữ liệu + Sử dụng định tuyến riêng tư với các máy chủ không xác định. + Sử dụng %s + Sử dụng các máy chủ + Dùng ứng dụng khi đang trong cuộc gọi. + Xác minh kết nối + Đang sử dụng các máy chủ SimpleX Chat. + Xác minh các kết nối + Sử dụng cho các tin nhắn + Tên người dùng + Sử dụng các thông tin đăng nhập ngẫu nhiên + Sử dụng các dịch vụ .onion + Xác minh mã bảo mật + Sử dụng hồ sơ ẩn danh mới + qua %1$s + Sử dụng proxy SOCKS + Sử dụng SimpleX Chat + Sử dụng cho các kết nối mới + Sử dụng máy chủ + Sử dụng proxy SOCKS? + Sử dụng các máy chủ SimpleX Chat? + Xác minh mật khẩu + Xác minh mã với máy tính + Sử dụng mật khẩu ngẫu nhiên + Sử dụng từ máy tính + Sử dụng định tuyến riêng tư với các máy chủ không xác định khi địa chỉ IP không được bảo vệ. + Xem các điều kiện + qua đường dẫn dùng một lần + Các tin nhắn thoại bị cấm. + Lịch sử hữu hình + Cuộc gọi video + Video đã được gửi + Tin nhắn thoại… + Các tin nhắn thoại là không được cho phép + Tin nhắn thoại + Video + Tin nhắn thoại (%1$s) + Video bật + Các tin nhắn thoại bị cấm trong cuộc trò chuyện này. + Tin nhắn thoại + Qua giao thức kháng lượng tử an toàn. + qua đường dẫn nhóm + Qua trình duyệt + video + Xem các điều kiện đã được cập nhật + Xem sự cố + Video tắt + Video sẽ được nhận khi liên hệ của bạn hoàn thành việc tải nó lên. + Video sẽ được nhận khi liên hệ của bạn trực tuyến, xin vui lòng chờ hoặc kiểm tra lại sau! + Xem mã bảo mật + Các tệp và video với kích thước tối đa lên tới 1gb + cuộc gọi video + thông qua relay + Tin nhắn thoại + cuộc gọi video (không được mã hóa đầu cuối) + qua đường dẫn địa chỉ liên lạc + Video + Chúng tôi không lưu bất kỳ liên hệ hay tin nhắn nào của bạn (một khi đã được gửi) trên các máy chủ. + Website + Xin chào %1$s! + Khi có sẵn + Các tin nhắn thoại bị cấm! + Lời chào + Màu sơ cấp hình nền + Đang chờ hình ảnh + Đang chờ hình ảnh + đang chờ xác nhận… + Có gì mới + tuần + Lời chào + Màu nền hình nền + Đang chờ máy tính… + Đang chờ di động để kết nối: + Xin chào! + Đang chờ video + muốn kết nối với bạn! + Đang chờ tệp + đang chờ trả lời… + Lời chào quá dài + - tin nhắn thoại với thời lượng tối đa lên tới 5 phút.\n- tùy chỉnh thời gian để tự xóa.\n- lịch sử chỉnh sử. + Cảnh báo: bạn có thể mất một số dữ liệu! + Cảnh báo: khởi động kết nối trò chuyện trên nhiều thiết bị không được hỗ trợ và sẽ gây ra các lỗi gửi tin nhắn + Các máy chủ ICE WebRTC + Khi ứng dụng đang chạy + Khi kết nối các cuộc gọi video và âm thanh. + Khi IP bị ẩn + Đang chờ video + + Thay đổi xóa tin nhắn tự động? + Các tin nhắn trong cuộc trò chuyện này sẽ không bao giờ bị xóa. + Máy chủ XFTP + Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các máy chủ truyền tệp. + Sẽ được kích hoạt trong các cuộc trò chuyện trực tiếp! + Mạng ethernet có dây + Khóa sai hoặc kết nối không xác định - khả năng cao kết nối này đã bị xóa. + Mật khẩu sai rồi! + WiFi + + Mật khẩu cơ sở dữ liệu sai + Khi bạn chia sẻ một hồ sơ ẩn danh với ai đó, hồ sơ này sẽ được sử dụng cho các nhóm mà họ mời bạn tham gia. + mặc định (%s) + Xóa tin nhắn trò chuyện khỏi thiết bị của bạn. + Tắt xóa tin nhắn tự động? + Tắt tính năng xóa tin nhắn + Đặt tên cuộc trò chuyện… + Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các relay XFTP sau đây:\n%1$s. + Các máy chủ XFTP + Khi có nhiều hơn một bên vận hành được kích hoạt, không ai trong số họ có siêu dữ liệu để biết được ai trò chuyện với ai. + Với các tệp và đa phương tiện được mã hóa. + Mức sử dụng pin đã được giảm xuống. + Mức sử dụng pin đã được giảm xuống. + Với lời chào tùy chọn. + Khi mọi người gửi yêu cầu kết nối, bạn có thể chấp nhận hoặc từ chối nó. + Khóa sai hoặc địa chỉ khối tệp không xác định - khả năng cao tệp đã bị xóa. + 1 năm + Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận trong cuộc trò chuyện này sớm hơn thời gian được chọn sẽ bị xóa. + + Bạn có thể hiển thị nó cho các liên hệ SimpleX của mình thông qua Cài đặt. + Bạn có thể tạo nó sau + Bạn có thể thay đổi nói trong cài đặt Giao diện. + Bạn đang tham gia nhóm thông qua đường dẫn này. + Bạn có thể bật vào lúc sau thông qua Cài đặt + BẠN + Bạn có thể chia sẻ một đường dẫn hoặc mã QR - bất kỳ ai cũng sẽ có thể tham gia nhóm. Bạn sẽ không mất các thành viên của nhóm nếu sau này bạn xóa nó đi. + Bạn có thể thử một lần nữa. + Bạn đã kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. + Bạn đã có một hồ sơ trò chuyện với cùng một tên hiển thị. Xin vui lòng chọn một cái tên khác. + Bạn có thể gửi tin nhắn tới %1$s từ Các liên hệ được lưu trữ. + Bạn đã kết nối với %1$s rồi. + Bạn cho phép + Bạn không được kết nối với các máy chủ này. Định tuyến riêng tư được sử dụng để gửi tin nhắn tới chúng. + Bạn có thể định cấu hình các bên vận hành trong cài đặt Mạng & máy chủ. + Bạn đang kết nối thông qua đường dẫn dùng một lần này! + Bạn có thể thử một lần nữa. + Bạn được mời vào nhóm. Tham gia để kết nối với các thành viên nhóm. + Bạn được mời vào nhóm + bạn: %1$s + Bạn có thể tùy chỉnh các máy chủ thông qua cài đặt. + Bạn có thể đặt tên kết nối, để nhớ xem đường dẫn đã được chia sẻ với ai. + bạn là quan sát viên + Bạn có thể sao chép và giảm kích thước tin nhắn để gửi nó đi. + Bạn có thể bật chúng vào lúc sau thông qua cài đặt Quyền riêng tư & Bảo mật của ứng dụng. + Bạn có thể ẩn hoặc tắt thông báo một hồ sơ người dùng - giữ nó trong phần menu. + bạn đã chặn %s + Bạn đã chấp nhận kết nối + bạn + bạn được mời vào nhóm + Các cuộc gọi của bạn + Bạn có thể bật Khóa SimpleX thông qua Cài đặt. + Bạn không có cuộc trò chuyện nào + Bạn vẫn có thể xem cuộc hội thoại với %1$s trong danh sách các cuộc trò chuyện. + Bạn đã tham gia nhóm này + Bạn đã tham gia nhóm này. Đang kết nối tới thành viên nhóm đi mời. + Bạn đã không thể được xác minh; xin vui lòng thử lại. + bạn đã thay đổi chức vụ của mình thành %s + Bạn có thể lưu bản lưu trữ đã được xuất. + Cơ sở dữ liệu trò chuyện của bạn không được mã hóa - đặt mật khẩu để bảo vệ nó. + Bạn phải nhập mật khẩu mỗi lần ứng dụng khởi chạy - nó không được lưu trên thiết bị của bạn. + bạn đã rời + Bạn cần cho phép liên hệ của mình thực hiện cuộc gọi để có thể gọi cho họ. + bạn đã thay đổi chức vụ của %s thành %s + bạn đã thay đổi địa chỉ + Bạn đã yêu cầu kết nối thông qua địa chỉ này rồi! + Bạn đã mời một liên hệ + Bạn có thể chia sẻ địa chỉ của mình dưới dạng một đường dẫn hoặc mã QR - bất kỳ ai cũng có thể kết nối với bạn. + Bạn có thể xem đường dẫn mời lần nữa trong chi tiết kết nối. + Bạn kiểm soát cuộc trò chuyện của mình! + Bạn không thể gửi tin nhắn! + Bạn có thể chia sẻ địa chỉ này với các liên hệ của mình để họ kết nối với %s. + Bạn cần cho phép liên hệ của mình gửi tin nhắn thoại để có thể gửi cho họ. + Bạn có thể di chuyển cơ sở dữ liệu đã được xuất. + Bạn CHỈ nên sử dụng phiên bản gần nhất của cơ sở dữ liệu trò chuyện của mình trên một thiết bị mà thôi, nếu không thì bạn có thể dừng nhận tin nhắn từ một vài liên hệ. + bạn đã thay đổi địa chỉ cho %s + Bạn có thể sử dụng markdown để định dạng tin nhắn: + Bạn có thể bắt đầu kết nối trò chuyện thông qua phần Cài đặt / Cơ sở dữ liệu ở trên ứng dụng hoặc bằng cách khởi động lại ứng dụng. + Bạn quyết định ai có thể kết nối tới. + Cơ sở dữ liệu trò chuyện của bạn + Sự riêng tư của bạn + Cơ sở dữ liệu trò chuyện hiện tại của bạn sẽ bị XÓA và THAY THẾ bằng cái được nhập vào.\nViệc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. + bạn đã xóa %1$s + Các liên hệ của bạn vẫn sẽ được kết nối. + Hồ sơ, các liên hệ và những tin nhắn đã được gửi của bạn được lưu trữ trên thiết bị bạn dùng. + Các liên hệ của bạn có thể cho phép xóa tin nhắn hoàn toàn. + Thu phóng + Các máy chủ SMP của bạn + Bạn đã chia sẻ đường dẫn dùng một lần + Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên nhóm + Hồ sơ hiện tại của bạn + Bạn đang dùng một hồ sơ ẩn danh cho nhóm này - để tránh khỏi chia sẻ hồ sơ chính của mình với các liên hệ đi mời mà không được cho phép + Các tùy chọn của bạn + Hồ sơ ngẫu nhiên của bạn + Bạn đã gửi lời mời nhóm + Bạn đã từ chối lời mời nhóm + bạn đã bỏ chặn %s + Hồ sơ trò chuyện của bạn sẽ được gửi\ntới liên hệ của bạn + bạn đã chia sẻ đường dẫn ẩn danh dùng một lần + Địa chỉ SimpleX của bạn + Liên hệ của bạn cần phải trực tuyến để cho kết nối hoàn thành.\nBạn có thể hủy kết nối này và xóa liên hệ (và thử lại sau với một đường dẫn mới). + Các máy chủ ICE của bạn + Bạn đang cố mời liên hệ mà bạn đã chia sẻ một hồ sơ ẩn danh với để tham gia nhóm mà bạn đang dùng hồ sơ chính của mình + Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên có liên lạc + Bạn sẽ được kết nối khi yêu cầu kết nối của bạn được chấp nhận, xin vui lòng đợi hoặc kiểm tra sau! + Hồ sơ %1$s sẽ được chia sẻ. + Các máy chủ XFTP của bạn + Hồ sơ của bạn sẽ được gửi tới liên hệ mà bạn đã nhận từ người đó đường dẫn này. + Bạn sẽ kết nối với tất cả các thành viên nhóm. + Bạn đã chia sẻ một đường dẫn tệp không hợp lệ. Báo cáo vấn đề tới các nhà phát triển ứng dụng. + Các máy chủ của bạn + Bạn vẫn sẽ nhận các cuộc gọi và thông báo từ các hồ sơ đã bị tắt thông báo khi chúng hoạt động. + Yêu cầu kết nối sẽ được gửi tới thành viên nhóm này. + Bạn sẽ dừng nhận tin nhắn từ cuộc hội thoại này. Lịch sử trò chuyện sẽ được giữ lại. + Bạn sẽ dừng nhận tin nhắn từ nhóm này. Lịch sử trò chuyện sẽ được giữ lại. + Liên hệ của bạn đã gửi một tệp có kích thước lớn hơn so với kích thước tối đa hiện đang được hỗ trợ (%1$s). + Bạn sẽ không mất các liên hệ của mình nếu bạn sau đó xóa địa chỉ của mình đi. + Các liên hệ của bạn + Các hồ sơ trò chuyện của bạn + Cài đặt của bạn + Máy chủ của bạn + Địa chỉ máy chủ của bạn + Các máy chủ ICE của bạn + Thông tin định danh của bạn có thể bị gửi mà không được mã hóa. + Hồ sơ của bạn được lưu trên thiết bị bạn dùng và chỉ được chia sẻ với các liên hệ bạn có. Các máy chủ SimpleX không thể xem hồ sơ của bạn. + Kết nối của bạn đã bị chuyển tới %s nhưng một lỗi không mong muốn đã xảy ra trong khi chuyển hướng bạn đến hồ sơ. + Bạn sẽ được kết nối tới nhóm khi thiết bị của chủ nhóm trực tuyến, xin vui lòng đợi hoặc kiểm tra sau! + Bạn sẽ được kết nối khi thiết bị của liên hệ bạn trực tuyến, xin vui lòng đợi hoặc kiểm tra sau! + Bạn sẽ được yêu cầu xác thực khi bạn khởi động hoặc tiếp tục ứng dụng sau 30 giây trong nền. + Cổng TCP để nhắn tin + Sử dụng cổng TCP %1$s khi không có cổng nào được chỉ định. + Sử dụng cổng web + Bạn có thể nhắc đến tối đa là %1$s thành viên trong một tin nhắn! + Tắt thông báo tất cả + Những lời nhắc chưa đọc + Các thành viên có thể báo cáo tin nhắn tới các kiểm duyệt viên. + Tất cả các báo cáo sẽ được lưu trữ cho bạn. + Lưu trữ tất cả báo cáo? + Lưu trữ %d báo cáo? + Lưu trữ các báo cáo + Cho tất cả các kiểm duyệt viên + Cho tôi + Báo cáo: %s + Cho phép báo cáo tin nhắn tới các kiểm duyệt viên. + Cấm báo cáo tin nhắn tới các kiểm duyệt viên. + Báo cáo tin nhắn bị cấm trong nhóm này. + Nhận thông báo khi được nhắc đến. + Giúp các quản trị viên quản lý các nhóm của họ. + Nhắc đến các thành viên 👋 + Sắp xếp các cuộc trò chuyện thành danh sách + Xóa nhóm nhanh hơn. + Gửi tin nhắn nhanh hơn. + Không còn bỏ lỡ các tin nhắn quan trọng nữa. + Bảo mật và riêng tư hơn + Nhóm hoạt động hiệu quả hơn + Gửi các báo cáo riêng tư + đã từ chối + đã từ chối + Thiết lập giờ hết hạn cho tin nhắn trong các cuộc trò chuyện. + Tên các tệp tin đa phương tiện riêng tư. + Lỗi đọc mật khẩu cơ sở dữ liệu + Tất cả các tin nhắn mới từ những thành viên này sẽ bị ẩn! + Chặn các thành viên cho tất cả? + các kiểm duyệt viên + Các tin nhắn từ những thành viên này sẽ được hiển thị! + Các thành viên sẽ bị xóa khỏi nhóm - việc này không thể được hoàn tác! + Các thành viên sẽ bị xóa khỏi cuộc trò chuyện - việc này không thể được hoàn tác! + Không thể đọc mật khẩu trong Keystore. Điều này có thể xảy ra sau bản cập nhật hệ thống không tương thích với ứng dụng. Nếu không phải như vậy, xin vui lòng liên hệ với các nhà phát triển. + đang chờ xử lý + Các điều kiện đã được cập nhật + Không thể đọc mật khẩu trong Keystore, xin vui lòng nhập thủ công. Điều này có thể xảy ra sau khi bản cập nhật hệ thống không tương thích với ứng dụng. Nếu không phải như vậy, vui lòng liên hệ với các nhà phát triển. + đang chờ phê duyệt + Bỏ chặn các thành viên cho tất cả? + Xóa các thành viên? + Chính sách quyền riêng tư và các điều kiện sử dụng. + Bằng việc sử dụng SimpleX Chat, bạn đồng ý:\n- chỉ gửi nội dung hợp pháp trong các nhóm công khai.\n- tôn trọng những người dùng khác - không gửi tin rác. + Các cuộc trò chuyện riêng tư, nhóm và liên hệ của bạn không thể truy cập được đối với các bên vận hành máy chủ. + Chấp nhận + Định cấu hình các bên vận hành máy chủ + Đường dẫn này yêu cầu một phiên bản ứng dụng mới hơn. Vui lòng nâng cấp ứng dụng hoặc yêu cầu liên hệ của một gửi cho một đường dẫn tương thích. + Đường dẫn kênh SimpleX + Đường dẫn kết nối không được hỗ trợ + Sử dụng đường dẫn ngắn (BETA) + Toàn bộ đường dẫn + Đường dẫn ngắn + Tắt + Các máy chủ cài sẵn + Chỉ sử dụng cổng TCP 443 cho các máy chủ cài sẵn. + Tất cả máy chủ + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 0477307343..e78878000c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -229,7 +229,7 @@ 按聊天资料(默认)或按连接(BETA)。 检查服务器地址并重试。 清除验证 - 关闭按键 + 关闭 配置 ICE 服务器 确认 确认你的证书 @@ -929,7 +929,7 @@ 观察员 你是观察者 更新群链接错误 - 你无法发送消息! + 你是观察员 初始角色 请联系群管理员。 系统 @@ -1059,7 +1059,7 @@ 当你或你的连接使用旧数据库备份时,可能会发生这种情况。 解密错误 请向开发者报告。 - 上一条消息的散列不同。 + 上条消息的哈希值不同。 下一条消息的 ID 不正确(小于或等于上一条)。 \n它可能是由于某些错误或连接被破坏才发生。 停止文件 @@ -1068,10 +1068,10 @@ 停止接收文件? 即将停止接收文件。 停止 - 撤销文件 - 撤销文件? + 吊销文件 + 吊销文件? 文件将从服务器中删除。 - 撤销 + 吊销 音频/视频通话 " \n在 v5.1 版本中可用" @@ -1232,7 +1232,7 @@ 中止地址更改 允许发送文件和媒体。 文件和媒体 - 只有组主可以启用文件和媒体。 + 只有群主可以启用文件和媒体。 文件和媒体被禁止。 成员可以发送文件和媒体。 禁止发送文件和媒体。 @@ -1330,7 +1330,7 @@ 更改密码或重启应用后,密码将以明文形式保存在设置中。 粘贴你收到的链接以与你的联系人联系… 送达回执 - 没有选择聊天 + 没有选中的聊天 可以加密 重新协商加密 禁用(保留组覆盖) @@ -1917,8 +1917,7 @@ 重置 服务器地址不兼容网络设置:%1$s。 起始自 %s。 - 起始自 %s. -\n所有数据都是设备的私有数据。 + 自 %s 起 \n所有数据均私密地保存在你的设备上. 已订阅 已认可 服务器版本不兼容你的应用:%1$s. @@ -1962,7 +1961,7 @@ 转发服务器 %1$s 连接目的地服务器 %2$s 失败。请稍后尝试。 转发服务器地址不兼容网络设置:%1$s。 转发服务器版本不兼容网络设置:%1$s。 - %1$s 的目的地服务器地址不兼容转发服务器 %2$s 的设置 + %1$s 的目的地服务器地址不兼容转发服务器 %2$s 的设置。 连接转发服务器 %1$s 出错。请稍后尝试。 模糊媒体文件 中度 @@ -2062,7 +2061,7 @@ 用户名 分享配置文件 转发消息出错 - 在你选中消息后这些消息被删除。 + 在你选中消息后这些消息已被删除。 %1$d 个文件错误:\n%2$s 其他 %1$d 个文件错误。 %1$d 个文件未被下载。 @@ -2071,29 +2070,29 @@ 没什么可转发的! 仍有 %1$d 个文件在下载中。 %1$d 个文件下载失败。 - %1$d 个文件被删除了。 + 删除了 %1$d 个文件。 下载 %1$s 条消息未被转发 转发消息… 转发 %1$s 条消息 保存 %1$s 条消息 已静音 - 管理形状 + 消息形状 拐角 尾部 初始化 WebView 出错。确保你安装了 WebView 且其支持的架构为 arm64。\n错误:%s 应用会话 - 每次启动应用都会使用新的 SOCKS5 凭据。 + 每次启动应用都会使用新的 SOCKS 凭据。 服务器 打开 Safari 设置/网站/麦克风,接着在 localhost 选择“允许”。 要进行通话,请允许使用设备麦克风。结束通话并尝试再次呼叫。 - 单击地址附近的\"信息\"按钮允许使用麦克风。 - 每个服务器都会使用新的 SOCKS5 凭据。 + 单击地址栏附近的“信息”按钮允许使用麦克风。 + 每个服务器都会使用新的 SOCKS 凭据。 更好的消息日期。 更佳的安全性✅ 更佳的使用体验 可自定义消息形状。 - 一次性转发最多20条消息。 + 一次转发最多20条消息。 Trail of Bits 审核了 SimpleX 协议。 通话期间切换音频和视频。 对一次性邀请切换聊天配置文件。 @@ -2117,21 +2116,21 @@ 创建一次性链接 用于社交媒体 或者私下分享 - 服务器运营者 - 网络运营者 - 30 天后将接受已启用的运营者的条款。 + 服务器运营方 + 网络运营方 + 30 天后将接受已启用的运营方的条款。 继续 稍后审阅 - 选择要使用的网络运营者。 + 选择要使用的网络运营方。 更新 你可以通过设置配置服务器。 - %s.]]> - 将于下列日期自动接受已启用的运营者的条款:%s。 + %s.]]> + 将于下列日期自动接受已启用的运营方的条款:%s。 预设服务器 你的服务器 接受条款的将来日期为:%s。 - 网络运营者 - 运营者 + 网络运营方 + 运营方 %s 台服务器 网站 无法加载当前条款文本,你可以通过此链接审阅条款: @@ -2148,33 +2147,33 @@ 用于消息 打开更改 打开条款 - 运营者服务器 - 已添加服务器到运营者 %s - 服务器运营者已更改。 + 运营方服务器 + 已添加服务器到运营方 %s + 服务器运营方已更改。 服务器协议已更改。 透明度 网络去中心化 - 应用中的第二个预设运营者! + 应用中的第二个预设运营方! 改进了聊天导航 查看更新后的条款 比如,如果你通过 SimpleX 服务器收到消息,应用会通过 Flux 服务器传送它们。 - 应用通过在每个对话中使用不同运营者保护你的隐私。 + 应用通过在每个对话中使用不同运营方保护你的隐私。 接受条款 模糊 - 地址或一次性链接? + 地址还是一次性链接? 已添加消息服务器 已添加媒体和文件服务器 地址设置 - 已接受条款 + 已接受的条款 应用工具栏 仅用于一名联系人 - 面对面或通过任何消息应用分享.]]> - %s.]]> - %s.]]> - %s.]]> - %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> %s的服务器,请接受使用条款。]]> - %s.]]> - 开启 flux + %s.]]> + 在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。 接受条款出错 为了更好的元数据隐私。 添加服务器出错 @@ -2190,10 +2189,10 @@ 此消息被删除或尚未收到。 连接达到了未送达消息上限,你的联系人可能处于离线状态。 为了防止链接被替换,你可以比较联系人安全代码。 - 你可以在“网络和服务器”设置中配置运营者。 - 接受运营者条款的日期:%s + 你可以在“网络和服务器”设置中配置运营方。 + 接受运营方条款的日期:%s 远程移动设备 - 或者导入压缩文件 + 或者导入存档文件 小米设备:请在系统设置中开启“自动启动”让通知正常工作。]]> 消息太大! 你可以复制并减小消息大小来发送它。 @@ -2229,8 +2228,199 @@ 聊天 将从聊天中删除成员 - 此操作无法撤销! 请减小消息尺寸并再次发送。 - 当启用了超过一个运营者时,没有一个运营者拥有了解谁和谁联络的元数据。 + 当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。 已接受邀请 被请求连接 - 关于运营者 - \ No newline at end of file + 关于运营方 + SimpleX Chat 和 Flux 达成协议将 Flux 运营的服务器包括在应用中。 + 修复 + 修复连接? + 正进行加密重协商。 + 连接需要加密重协商。 + 保存数据库出错 + 启用日志 + 连接未就绪。 + 创建聊天列表出错 + 更新聊天列表出错 + 加载聊天列表出错 + 列表 + 无聊天 + 找不到聊天 + %s列表中没有聊天。 + 收藏 + + 列表名… + 所有列表的名称和表情符号都应不同。 + 保存列表 + 删除 + 用%s打开 + 将从%s列表删除所有聊天,列表本身也将被删除 + 企业 + 编辑 + 添加到列表 + 联系人 + 创建列表 + 添加列表 + 全部 + 删除列表? + 没有未读聊天 + 附注 + 更改列表 + 更改顺序 + 创建报告出错 + 保存设置出错 + 存档 + 删除举报 + 举报 + 举报其他:仅moderators会看到。 + 举报成员个人资料:仅moderators会看到。 + 举报违规:仅moderators会看到。 + 存档举报 + 举报内容:仅moderators会看到。 + 举报垃圾信息:仅moderators会看到。 + moderators + 另一个理由 + 已存档的举报 + 违反社区指导方针 + 不当内容 + 不当个人资料 + 仅发送人和moderators能看到 + 只有你和moderators能看到 + 垃圾信息 + 存档举报? + 举报理由? + 将为你存档该举报。 + 存档了 %s 的举报 + 举报 + 1 个举报 + 成员举报 + %d 个举报 + 垃圾信息 + 连接被阻止 + 连接被服务器运营方阻止:\n%1$s. + 内容违反使用条款 + 文件被服务器运营方阻止:\n%1$s. + 询问 + + + 打开 web 链接? + 打开链接 + 打开来自聊天列表的链接 + 设置聊天名称… + 停用消息自动删除? + 停用消息删除 + 1 年 + 默认(%s) + 从你的设备删除聊天消息。 + 此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。 + 此聊天中的消息永远不会被删除。 + 更改消息自动删除设置? + 用于消息收发的 TCP 端口 + 未指定端口时,使用 TCP 端口 %1$s。 + 使用 web 端口 + 全部静音 + 取消提及的已读状态 + 每条消息最多提及 %1$s 名成员! + 将为你存档所有举报。 + 存档所有举报? + 存档 %d 份举报? + 存档举报 + 所有 moderators + 仅自己 + 举报:%s + 禁止向 moderators 举报消息。 + 此群禁止消息举报。 + 成员可以向 moderators 举报消息。 + 允许向 moderators 举报消息。 + 提及成员👋 + 更好的群性能 + 更好的隐私和安全 + 不错过重要消息。 + 更快地删除群。 + 更快发送消息。 + 被提及时收到通知。 + 帮助管理员管理群组。 + 将聊天组织到列表 + 私密媒体文件名。 + 发送私下举报 + 在聊天中设置消息过期时间。 + 被拒绝 + 被拒绝 + 数据库密码短语读取出错 + 无法读取 Keystore 中的密码短语,请手动输入它。这可能在不兼容本应用的系统更新后出现。如果不是这种情况,请联系开发者。 + 无法读取 Keystore 中的密码短语,请手动输入它。这可能在不兼容本应用的系统更新后出现。如果不是这种情况,请联系开发者。 + 待批准 + 待批准 + 条款已更新 + 所有来自这些成员的新消息都将被隐藏! + 为所有其他成员封禁这些成员? + 将从群中移除这些成员 — 此操作无法撤销! + 将显示来自这些成员的消息! + 删除成员吗? + 为所有其他成员解封这些成员吗? + moderators + 将从聊天中移除这些成员 — 此操作无法撤销! + 隐私政策和使用条款。 + 接受 + 使用 SimpleX Chat 代表您同意:\n- 在公开群中只发送合法内容\n- 尊重其他用户 – 没有垃圾信息。 + 服务器运营方无法访问私密聊天、群组和你的联系人。 + 配置服务器运营方 + 不支持的连接链接 + 使用短链接(测试) + SimpleX 频道链接 + 短链接 + 此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。 + 完整链接 + 全部服务器 + 关闭 + 预设服务器 + 仅预设服务器使用 TCP 协议 443 端口。 + 接受成员出错 + 举报已发送至 moderators + %d 个聊天 + 和成员的 %d 个聊天 + %d 条消息 + 接受了 %1$s + 接受了你 + 你接受了该成员 + 新成员要加入本群。 + 审核 + 待审核 + 全部 + 成员准入 + 关闭 + 删除 + 接受 + 和成员聊天 + 和管理员聊天 + 没有和成员的聊天 + 接受为成员 + 接受成员 + 成员将加入本群,接受成员吗? + 由管理员审核 + 设置成员入群准许 + 和成员聊天 + 和管理员聊天 + 准许入群前审核成员(knocking)。 + 请等待群的 moderator 审核你加入该群的请求。 + 审核成员 + 保存入群设置? + 你可以在和管理员和聊天中查看你的举报。 + 接受为观察员 + 和一名成员的一个聊天 + 无法发送消息 + 你离开了 + 删除和成员的聊天出错 + 你无法发送消息! + 禁用了联系人 + 群被删除了 + 从群被删除了 + 加入请求被拒绝 + 删除聊天 + 删除和成员的聊天吗? + 未同步 + 成员有旧版本 + 删除了联系人 + 联系人未就绪 + 拒绝成员? + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index fd58811439..8ae414de00 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1495,7 +1495,7 @@ %s 的版本。請檢察兩台裝置安裝的是否版本相同]]> 更可靠的網路連接 發現和加入群組 - 裝置 + 設備 新行動裝置 保存設定出錯 導出的檔案不存在 @@ -1628,7 +1628,7 @@ 找到桌面 自動連接 與PC版的連接不穩定 - 桌面 + 桌上電腦 已安裝的PC版本不支援。請確認兩台裝置所安裝的版本相同 PC版邀請碼錯誤 通過連結連接? @@ -1790,4 +1790,4 @@ 更新 接收總計 稍後提醒 - \ No newline at end of file + 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/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 53f3301507..d0ba082adf 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -133,9 +133,9 @@ actual fun ImageBitmap.hasAlpha(): Boolean { return false } -actual fun ImageBitmap.addLogo(): ImageBitmap { - val radius = (width * 0.16f).toInt() - val logoSize = (width * 0.24).toInt() +actual fun ImageBitmap.addLogo(size: Float): ImageBitmap { + val radius = (width * size).toInt() + val logoSize = (width * size * 1.5).toInt() val logo: BufferedImage = MR.images.icon_foreground_common.image val original = toAwtImage() val withLogo = BufferedImage(width, height, original.type) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index e7bcf4802a..41964b7d18 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -10,8 +10,7 @@ import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* @@ -45,25 +44,27 @@ import kotlin.text.substring actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + disabledText: String?, sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, - userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { + val cs = composeState.value - val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current + val focusReq = focusRequester ?: remember { FocusRequester() } LaunchedEffect(cs.contextItem) { if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect // In replying state - focusRequester.requestFocus() + focusReq.requestFocus() delay(50) keyboard?.show() } @@ -74,9 +75,9 @@ actual fun PlatformTextField( keyboard?.hide() } } - val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.subSequence(0, min(50, cs.message.length)))) } + val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length)))) } val isRtlByCharacters = remember(cs.message) { - if (cs.message.isNotEmpty()) isRtl(cs.message.subSequence(0, min(50, cs.message.length))) else lastTimeWasRtlByCharacters.value + if (cs.message.text.isNotEmpty()) isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length))) else lastTimeWasRtlByCharacters.value } LaunchedEffect(isRtlByCharacters) { lastTimeWasRtlByCharacters.value = isRtlByCharacters @@ -84,12 +85,12 @@ actual fun PlatformTextField( val isLtrGlobally = LocalLayoutDirection.current == LayoutDirection.Ltr // Different padding here is for a text that is considered RTL with non-RTL locale set globally. // In this case padding from right side should be bigger - val startEndPadding = if (cs.message.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp + val startEndPadding = if (cs.message.text.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding val padding = PaddingValues(startPadding, 12.dp, endPadding, 0.dp) - var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } - val textFieldValue = textFieldValueState.copy(text = cs.message) + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message.text, selection = cs.message.selection)) } + val textFieldValue = textFieldValueState.copy(text = cs.message.text, selection = cs.message.selection) val clipboard = LocalClipboardManager.current BasicTextField( value = textFieldValue, @@ -105,7 +106,7 @@ actual fun PlatformTextField( } } textFieldValueState = it - onMessageChange(it.text) + onMessageChange(ComposeMessage(it.text, it.selection)) } }, textStyle = textStyle.value, @@ -118,7 +119,7 @@ actual fun PlatformTextField( .padding(start = startPadding, end = endPadding) .offset(y = (-5).dp) .fillMaxWidth() - .focusRequester(focusRequester) + .focusRequester(focusReq) .onPreviewKeyEvent { if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyDown) { if (it.isShiftPressed) { @@ -129,12 +130,12 @@ actual fun PlatformTextField( text = newText, selection = TextRange(textFieldValue.selection.min + 1) ) - onMessageChange(newText) + onMessageChange(ComposeMessage(newText, textFieldValueState.selection)) } else if (!sendMsgButtonDisabled) { onDone() } true - } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { + } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.text.isEmpty()) { onUpArrow() true } else if (it.key == Key.V && @@ -166,7 +167,7 @@ actual fun PlatformTextField( chatModel.filesToDelete.add(tempFile) tempFile.writeBytes(bytes) - composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message) + composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message.text) } } catch (e: Exception) { Log.e(TAG, "Pasting image exception: ${e.stackTraceToString()}") @@ -200,18 +201,18 @@ actual fun PlatformTextField( } } ) - showDeleteTextButton.value = cs.message.split("\n").size >= 4 && !cs.inProgress + showDeleteTextButton.value = cs.message.text.split("\n").size >= 4 && !cs.inProgress if (composeState.value.preview is ComposePreview.VoicePreview) { - ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) - } else if (userIsObserver) { - ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) + ComposeOverlay(generalGetString(MR.strings.voice_message_send_text), textStyle, padding) + } else if (disabledText != null) { + ComposeOverlay(disabledText, textStyle, padding) } } @Composable -private fun ComposeOverlay(textId: StringResource, textStyle: MutableState, padding: PaddingValues) { +private fun ComposeOverlay(text: String, textStyle: MutableState, padding: PaddingValues) { Text( - generalGetString(textId), + text, Modifier.padding(padding), color = MaterialTheme.colors.secondary, style = textStyle.value.copy(fontStyle = FontStyle.Italic) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 3f5703365d..7a2a1dff0a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -111,7 +111,9 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, - content: LazyListScope.() -> Unit + maxHeight: State?, + containerAlignment: Alignment, + content: LazyListScope.() -> Unit, ) { val scope = rememberCoroutineScope() val scrollBarAlpha = remember { Animatable(0f) } @@ -135,9 +137,11 @@ actual fun LazyColumnWithScrollBarNoAppBar( // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state // (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row) val scrollBarDraggingState = remember { mutableStateOf(false) } - Box { + Box(contentAlignment = containerAlignment) { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) + Box(if (maxHeight?.value != null) Modifier.height(maxHeight.value).fillMaxWidth() else Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { + DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout, scrollBarDraggingState) + } } } 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/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt index 3855835ab6..52e845b422 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt @@ -63,7 +63,7 @@ actual fun UserPickerUsersSection( ProfileImage(size = 55.dp, image = user.profile.image, color = iconColor) if (u.unreadCount > 0 && !user.activeUser) { - unreadBadge(u.unreadCount, user.showNtfs, true) + userUnreadBadge(u.unreadCount, user.showNtfs, true) } } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 6c04bf65d7..db12d47dbd 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,12 +24,15 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.3-beta.1 -android.version_code=270 +android.version_name=6.4-beta.1 +android.version_code=291 -desktop.version_name=6.3-beta.1 -desktop.version_code=88 +desktop.version_name=6.4-beta.1 +desktop.version_code=103 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 compose.version=1.7.0 + +# Choose sqlite or postgres backend +database.backend=sqlite diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 6c3d8240e4..40e6686065 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -43,12 +43,12 @@ mySquaringBot :: User -> ChatController -> IO () mySquaringBot _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - case resp of - CRContactConnected _ contact _ -> do + (_, evt) <- atomically . readTBQueue $ outputQ cc + case evt of + Right (CEvtContactConnected _ contact _) -> do contactConnected contact sendMessage cc contact welcomeMessage - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do + Right CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do let msg = ciContentToText mc number_ = readMaybe (T.unpack msg) :: Maybe Integer sendMessage cc contact $ case number_ of diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 9dc927af9e..86f89f86e8 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -2,6 +2,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module Broadcast.Bot where @@ -36,38 +37,31 @@ broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - case resp of - CRContactConnected _ ct _ -> do + (_, evt) <- atomically . readTBQueue $ outputQ cc + case evt of + Right (CEvtContactConnected _ ct _) -> do contactConnected ct sendMessage cc ct welcomeMessage - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) : _} - | publisher `elem` publishers -> - if allowContent mc - then do - sendChatCmd cc ListContacts >>= \case - CRContactsList _ cts -> void . forkIO $ do - let cts' = filter broadcastTo cts - forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc - sendReply $ "Forwarded to " <> tshow (length cts') <> " contact(s)" - r -> putStrLn $ "Error getting contacts list: " <> show r - else sendReply "!1 Message is not supported!" - | otherwise -> do - sendReply prohibitedMessage - deleteMessage cc ct $ chatItemId' ci + Right CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) : _} + | sender `notElem` publishers -> do + sendReply prohibitedMessage + deleteMessage cc ct $ chatItemId' ci + | allowContent mc -> + void $ forkIO $ + sendChatCmd cc (SendMessageBroadcast mc) >>= \case + Right CRBroadcastSent {successes, failures} -> + sendReply $ "Forwarded to " <> tshow successes <> " contact(s), " <> tshow failures <> " errors" + r -> putStrLn $ "Error broadcasting message: " <> show r + | otherwise -> + sendReply "!1 Message is not supported!" where sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . MCText - publisher = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} + sender = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} allowContent = \case MCText _ -> True MCLink {} -> True MCImage {} -> True _ -> False - broadcastTo Contact {activeConn = Nothing} = False - broadcastTo ct'@Contact {activeConn = Just conn@Connection {connStatus}} = - (connStatus == ConnSndReady || connStatus == ConnReady) - && not (connDisabled conn) - && contactId' ct' /= contactId' ct _ -> pure () where contactConnected ct = putStrLn $ T.unpack (localDisplayName' ct) <> " connected" diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index e695b5069d..8107b664c4 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -75,7 +75,6 @@ mkChatOpts :: BroadcastBotOpts -> ChatOpts mkChatOpts BroadcastBotOpts {coreOptions} = ChatOpts { coreOptions, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index fddad1cf2c..0906d14536 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -2,17 +2,23 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE UndecidableInstances #-} module Server where import Control.Monad import Control.Monad.Except import Control.Monad.Reader -import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson (FromJSON, ToJSON (..)) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import Data.Bifunctor (first) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import GHC.Generics (Generic) @@ -23,11 +29,44 @@ import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) import Simplex.Messaging.Transport.Server (runLocalTCPServer) import Simplex.Messaging.Util (raceAny_) import UnliftIO.Exception import UnliftIO.STM +data ChatSrvRequest = ChatSrvRequest {corrId :: Text, cmd :: Text} + deriving (Generic, FromJSON) + +data ChatSrvResponse r = ChatSrvResponse {corrId :: Maybe Text, resp :: CSRBody r} + +data CSRBody r = CSRBody {csrBody :: Either ChatError r} + +-- backwards compatible encoding, to avoid breaking any chat bots +data ObjChatCmdError = ObjChatCmdError {chatError :: ChatError} + +data ObjChatError = ObjChatError {chatError :: ChatError} + +$(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "Obj") ''ObjChatCmdError) + +$(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "Obj") ''ObjChatError) + +instance ToJSON (CSRBody ChatResponse) where + toJSON = toJSON . first ObjChatCmdError . csrBody + toEncoding = toEncoding . first ObjChatCmdError . csrBody + +instance ToJSON (CSRBody ChatEvent) where + toJSON = toJSON . first ObjChatError . csrBody + toEncoding = toEncoding . first ObjChatError . csrBody + +data AChatSrvResponse = forall r. ToJSON (ChatSrvResponse r) => ACR (ChatSrvResponse r) + +$(pure []) + +instance ToJSON (CSRBody r) => ToJSON (ChatSrvResponse r) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''ChatSrvResponse) + toJSON = $(JQ.mkToJSON defaultJSON ''ChatSrvResponse) + simplexChatServer :: ServiceName -> ChatConfig -> ChatOpts -> IO () simplexChatServer chatPort cfg opts = simplexChatCore cfg opts . const $ runChatServer defaultChatServerConfig {chatPort} @@ -44,19 +83,9 @@ defaultChatServerConfig = clientQSize = 1 } -data ChatSrvRequest = ChatSrvRequest {corrId :: Text, cmd :: Text} - deriving (Generic, FromJSON) - -data ChatSrvResponse = ChatSrvResponse {corrId :: Maybe Text, resp :: ChatResponse} - deriving (Generic) - -instance ToJSON ChatSrvResponse where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - data ChatClient = ChatClient { rcvQ :: TBQueue (Text, ChatCommand), - sndQ :: TBQueue ChatSrvResponse + sndQ :: TBQueue AChatSrvResponse } newChatServerClient :: Natural -> STM ChatClient @@ -78,14 +107,14 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do getConnection sock = WS.makePendingConnection sock WS.defaultConnectionOptions >>= WS.acceptRequest send ws ChatClient {sndQ} = forever $ - atomically (readTBQueue sndQ) >>= WS.sendTextData ws . J.encode + atomically (readTBQueue sndQ) >>= \(ACR r) -> WS.sendTextData ws (J.encode r) client ChatClient {rcvQ, sndQ} = forever $ do atomically (readTBQueue rcvQ) >>= processCommand - >>= atomically . writeTBQueue sndQ + >>= atomically . writeTBQueue sndQ . ACR output ChatClient {sndQ} = forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - atomically $ writeTBQueue sndQ ChatSrvResponse {corrId = Nothing, resp} + (_, r) <- atomically . readTBQueue $ outputQ cc + atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId = Nothing, resp = CSRBody r} receive ws ChatClient {rcvQ, sndQ} = forever $ do s <- WS.receiveData ws case J.decodeStrict' s of @@ -96,11 +125,9 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do Left e -> sendError (Just corrId) e Nothing -> sendError Nothing "invalid request" where - sendError corrId e = atomically $ writeTBQueue sndQ ChatSrvResponse {corrId, resp = chatCmdError Nothing e} + sendError corrId e = atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId, resp = CSRBody $ chatCmdError e} processCommand (corrId, cmd) = - runReaderT (runExceptT $ processChatCommand cmd) cc >>= \case - Right resp -> response resp - Left e -> response $ CRChatCmdError Nothing e + response <$> runReaderT (runExceptT $ processChatCommand cmd) cc where - response resp = pure ChatSrvResponse {corrId = Just corrId, resp} + response r = ChatSrvResponse {corrId = Just corrId, resp = CSRBody r} clientDisconnected _ = pure () diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 0c6464dbfe..2091ab444b 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -5,6 +5,7 @@ module Main where import Directory.Options import Directory.Service import Directory.Store +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) import Simplex.Chat.Core import Simplex.Chat.Terminal (terminalChatConfig) @@ -14,4 +15,7 @@ main = do st <- restoreDirectoryStore directoryLog if runCLI then directoryServiceCLI st opts - else simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts + else do + env <- newServiceState opts + let cfg = terminalChatConfig {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} + simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts env diff --git a/apps/simplex-directory-service/src/Directory/BlockedWords.hs b/apps/simplex-directory-service/src/Directory/BlockedWords.hs new file mode 100644 index 0000000000..a29e2c99e0 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/BlockedWords.hs @@ -0,0 +1,77 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Directory.BlockedWords where + +import Data.Char (isMark, isPunctuation, isSpace) +import Data.List (isPrefixOf) +import Data.Maybe (fromMaybe) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Set (Set) +import qualified Data.Set as S +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Normalize as TN + +data BlockedWordsConfig = BlockedWordsConfig + { blockedWords :: Set Text, + blockedFragments :: Set Text, + extensionRules :: [(String, [String])], + spelling :: Map Char [Char] + } + +hasBlockedFragments :: BlockedWordsConfig -> Text -> Bool +hasBlockedFragments BlockedWordsConfig {spelling, blockedFragments} s = + any (\w -> any (`T.isInfixOf` w) blockedFragments) ws + where + ws = S.fromList $ filter (not . T.null) $ normalizeText spelling s + +hasBlockedWords :: BlockedWordsConfig -> Text -> Bool +hasBlockedWords BlockedWordsConfig {spelling, blockedWords} s = + not $ ws1 `S.disjoint` blockedWords && (length ws <= 1 || ws2 `S.disjoint` blockedWords) + where + ws = T.words s + ws1 = normalizeWords ws + ws2 = normalizeWords $ T.splitOn " " s + normalizeWords = S.fromList . filter (not . T.null) . concatMap (normalizeText spelling) + +normalizeText :: Map Char [Char] -> Text -> [Text] +normalizeText spelling' = + map (T.pack . filter (\c -> not $ isSpace c || isPunctuation c || isMark c)) + . allSubstitutions spelling' + . removeTriples + . T.unpack + . T.toLower + . TN.normalize TN.NFKD + +-- replaces triple and larger occurences with doubles +removeTriples :: String -> String +removeTriples xs = go xs '\0' False + where + go [] _ _ = [] + go (c : cs) prev samePrev + | prev /= c = c : go cs c False + | samePrev = go cs c True + | otherwise = c : go cs c True + +-- Generate all possible strings by substituting each character +allSubstitutions :: Map Char [Char] -> String -> [String] +allSubstitutions spelling' = sequence . map substs + where + substs c = fromMaybe [c] $ M.lookup c spelling' + +wordVariants :: [(String, [String])] -> String -> [Text] +wordVariants [] s = [T.pack s] +wordVariants (sub : subs) s = concatMap (wordVariants subs) (replace sub) + where + replace (pat, tos) = go s + where + go [] = [""] + go s'@(c : rest) + | pat `isPrefixOf` s' = + let s'' = drop (length pat) s' + restVariants = go s'' + in map (pat <>) restVariants + <> concatMap (\to -> map (to <>) restVariants) tos + | otherwise = map (c :) (go rest) diff --git a/apps/simplex-directory-service/src/Directory/Captcha.hs b/apps/simplex-directory-service/src/Directory/Captcha.hs new file mode 100644 index 0000000000..d60b09df83 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Captcha.hs @@ -0,0 +1,37 @@ +module Directory.Captcha (getCaptchaStr, matchCaptchaStr) where + +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import System.Random (randomRIO) + +getCaptchaStr :: Int -> String -> IO String +getCaptchaStr 0 s = pure s +getCaptchaStr n s = do + i <- randomRIO (0, length captchaChars - 1) + let c = captchaChars !! i + getCaptchaStr (n - 1) (c : s) + where + captchaChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +matchCaptchaStr :: T.Text -> T.Text -> Bool +matchCaptchaStr captcha guess = T.length captcha == T.length guess && matchChars (T.zip captcha guess) + where + matchChars [] = True + matchChars ((c, g) : cs) = matchChar c == matchChar g && matchChars cs + matchChar c = fromMaybe c $ M.lookup c captchaMatches + captchaMatches = + M.fromList + [ ('0', 'O'), + ('1', 'I'), + ('c', 'C'), + ('l', 'I'), + ('o', 'O'), + ('p', 'P'), + ('s', 'S'), + ('u', 'U'), + ('v', 'V'), + ('w', 'W'), + ('x', 'X'), + ('z', 'Z') + ] diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 19c9405358..aa2374f919 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -11,25 +11,27 @@ module Directory.Events ( DirectoryEvent (..), DirectoryCmd (..), ADirectoryCmd (..), + DirectoryHelpSection (..), DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, directoryCmdTag, - viewName, ) where -import Control.Applicative ((<|>)) +import Control.Applicative (optional, (<|>)) import Data.Attoparsec.Text (Parser) 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) import Directory.Store import Simplex.Chat.Controller +import Simplex.Chat.Markdown (displayNameTextP) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) @@ -44,7 +46,9 @@ 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 | DEServiceRoleChanged GroupInfo GroupMemberRole | DEContactRemovedFromGroup ContactId GroupInfo @@ -58,36 +62,47 @@ data DirectoryEvent | DELogChatResponse Text deriving (Show) -crDirectoryEvent :: ChatResponse -> Maybe DirectoryEvent +crDirectoryEvent :: Either ChatError ChatEvent -> Maybe DirectoryEvent 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_) - CRMemberRole {groupInfo, member, toRole} + Right evt -> crDirectoryEvent_ evt + Left e -> case e of + ChatErrorAgent {agentError = BROKER _ NETWORK} -> Nothing + ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing + _ -> Just $ DELogChatResponse $ "chat error: " <> tshow e + +crDirectoryEvent_ :: ChatEvent -> Maybe DirectoryEvent +crDirectoryEvent_ = \case + CEvtContactConnected {contact} -> Just $ DEContactConnected contact + CEvtReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} + CEvtUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember + CEvtGroupUpdated {fromGroup, toGroup, member_} -> (\member -> DEGroupUpdated {member, fromGroup, toGroup}) <$> member_ + CEvtJoinedGroupMember {groupInfo, member = m} + | pending m -> Just $ DEPendingMember groupInfo m + | otherwise -> Nothing + CEvtNewChatItems {chatItems = AChatItem _ _ (GroupChat g _scopeInfo) ci : _} -> case ci of + ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent (MCText t)} | pending m -> Just $ DEPendingMemberMsg g m (chatItemId' ci) t + _ -> Nothing + CEvtMemberRole {groupInfo, member, toRole} | groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member - CRDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember - CRLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member - CRDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo - CRGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo - CRChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct - CRChatItemsDeleted {chatItemDeletions = ((ChatItemDeletion (AChatItem _ SMDRcv (DirectChat ct) _) _) : _), byUser = False} -> Just $ DEItemDeleteIgnored ct - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}) : _} -> + CEvtDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember + CEvtLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member + CEvtDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo + CEvtGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo + CEvtChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct + CEvtChatItemsDeleted {chatItemDeletions = ((ChatItemDeletion (AChatItem _ SMDRcv (DirectChat ct) _) _) : _), byUser = False} -> Just $ DEItemDeleteIgnored ct + CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}) : _} -> Just $ case (mc, itemLive) of (MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.dropWhileEnd isSpace t _ -> DEUnsupportedMessage ct ciId where ciId = chatItemId' ci err = ADC SDRUser DCUnknownCommand - CRMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage - CRChatCmdError {chatError} -> Just $ DELogChatResponse $ "chat cmd error: " <> tshow chatError - CRChatError {chatError} -> case chatError of - ChatErrorAgent {agentError = BROKER _ NETWORK} -> Nothing - ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing - _ -> Just $ DELogChatResponse $ "chat error: " <> tshow chatError - CRChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) + CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage + CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) _ -> Nothing + where + pending m = memberStatus m == GSMemPendingApproval data DirectoryRole = DRUser | DRAdmin | DRSuperUser @@ -107,7 +122,8 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCConfirmDuplicateGroup_ :: DirectoryCmdTag 'DRUser DCListUserGroups_ :: DirectoryCmdTag 'DRUser DCDeleteGroup_ :: DirectoryCmdTag 'DRUser - DCSetRole_ :: DirectoryCmdTag 'DRUser + DCMemberRole_ :: DirectoryCmdTag 'DRUser + DCGroupFilter_ :: DirectoryCmdTag 'DRUser DCApproveGroup_ :: DirectoryCmdTag 'DRAdmin DCRejectGroup_ :: DirectoryCmdTag 'DRAdmin DCSuspendGroup_ :: DirectoryCmdTag 'DRAdmin @@ -117,14 +133,19 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCShowGroupLink_ :: DirectoryCmdTag 'DRAdmin DCSendToGroupOwner_ :: DirectoryCmdTag 'DRAdmin DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin + -- DCAddBlockedWord_ :: DirectoryCmdTag 'DRAdmin + -- DCRemoveBlockedWord_ :: DirectoryCmdTag 'DRAdmin DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser 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 @@ -133,7 +154,8 @@ data DirectoryCmd (r :: DirectoryRole) where DCConfirmDuplicateGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser DCListUserGroups :: DirectoryCmd 'DRUser DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser - DCSetRole :: GroupId -> GroupName -> GroupMemberRole -> DirectoryCmd 'DRUser + DCMemberRole :: UserGroupRegId -> Maybe GroupName -> Maybe GroupMemberRole -> DirectoryCmd 'DRUser + DCGroupFilter :: UserGroupRegId -> Maybe GroupName -> Maybe DirectoryMemberAcceptance -> DirectoryCmd 'DRUser DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRAdmin DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin @@ -143,6 +165,8 @@ data DirectoryCmd (r :: DirectoryRole) where DCShowGroupLink :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSendToGroupOwner :: GroupId -> GroupName -> Text -> DirectoryCmd 'DRAdmin DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin + -- DCAddBlockedWord :: Text -> DirectoryCmd 'DRAdmin + -- DCRemoveBlockedWord :: Text -> DirectoryCmd 'DRAdmin DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -163,7 +187,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_ @@ -174,7 +198,8 @@ directoryCmdP = "list" -> u DCListUserGroups_ "ls" -> u DCListUserGroups_ "delete" -> u DCDeleteGroup_ - "role" -> u DCSetRole_ + "role" -> u DCMemberRole_ + "filter" -> u DCGroupFilter_ "approve" -> au DCApproveGroup_ "reject" -> au DCRejectGroup_ "suspend" -> au DCSuspendGroup_ @@ -184,6 +209,8 @@ directoryCmdP = "link" -> au DCShowGroupLink_ "owner" -> au DCSendToGroupOwner_ "invite" -> au DCInviteOwnerToGroup_ + -- "block_word" -> au DCAddBlockedWord_ + -- "unblock_word" -> au DCRemoveBlockedWord_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -193,18 +220,52 @@ 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 - DCSetRole_ -> do - (groupId, displayName) <- gc (,) - memberRole <- A.space *> ("member" $> GRMember <|> "observer" $> GRObserver) - pure $ DCSetRole groupId displayName memberRole + DCMemberRole_ -> do + (groupId, displayName_) <- gc_ (,) + memberRole_ <- optional $ spacesP *> ("member" $> GRMember <|> "observer" $> GRObserver) + pure $ DCMemberRole groupId displayName_ memberRole_ + DCGroupFilter_ -> do + (groupId, displayName_) <- gc_ (,) + acceptance_ <- + (A.takeWhile isSpace >> A.endOfInput) $> Nothing + <|> Just <$> (acceptancePresetsP <|> acceptanceFiltersP) + pure $ DCGroupFilter groupId displayName_ acceptance_ + where + acceptancePresetsP = + spacesP + *> A.choice + [ "off" $> noJoinFilter, + "basic" $> basicJoinFilter, + ("moderate" <|> "mod") $> moderateJoinFilter, + "strong" $> strongJoinFilter + ] + acceptanceFiltersP = do + rejectNames <- filterP "name" + passCaptcha <- filterP "captcha" + makeObserver <- filterP "observer" + pure DirectoryMemberAcceptance {rejectNames, passCaptcha, makeObserver} + filterP :: Text -> Parser (Maybe ProfileCondition) + filterP s = Just <$> (spacesP *> A.string s *> conditionP) <|> pure Nothing + conditionP = + "=all" $> PCAll + <|> ("=noimage" <|> "=no_image" <|> "=no-image") $> PCNoImage + <|> pure PCAll DCApproveGroup_ -> do (groupId, displayName) <- gc (,) groupApprovalId <- A.space *> A.decimal @@ -220,22 +281,18 @@ directoryCmdP = msg <- A.space *> A.takeText pure $ DCSendToGroupOwner groupId displayName msg DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup - DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) + -- DCAddBlockedWord_ -> DCAddBlockedWord <$> wordP + -- DCRemoveBlockedWord_ -> DCRemoveBlockedWord <$> wordP + DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (spacesP *> A.takeText) where - gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP - displayNameP = quoted '\'' <|> takeNameTill (== ' ') - takeNameTill p = - A.peekChar' >>= \c -> - if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted c = A.char c *> takeNameTill (== c) <* A.char c - refChar c = c > ' ' && c /= '#' && c /= '@' - -viewName :: Text -> Text -viewName n = if any (== ' ') (T.unpack n) then "'" <> n <> "'" else n + gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) + gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) + -- wordP = spacesP *> A.takeTill isSpace + spacesP = A.takeWhile1 isSpace directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case - DCHelp -> "help" + DCHelp _ -> "help" DCSearchGroup _ -> "search" DCSearchNext -> "next" DCAllGroups -> "all" @@ -245,7 +302,8 @@ directoryCmdTag = \case DCListUserGroups -> "list" DCDeleteGroup {} -> "delete" DCApproveGroup {} -> "approve" - DCSetRole {} -> "role" + DCMemberRole {} -> "role" + DCGroupFilter {} -> "filter" DCRejectGroup {} -> "reject" DCSuspendGroup {} -> "suspend" DCResumeGroup {} -> "resume" @@ -254,6 +312,8 @@ directoryCmdTag = \case DCShowGroupLink {} -> "link" DCSendToGroupOwner {} -> "owner" DCInviteOwnerToGroup {} -> "invite" + -- DCAddBlockedWord _ -> "block_word" + -- DCRemoveBlockedWord _ -> "unblock_word" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index a62939b6ac..2c26905e79 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -1,5 +1,6 @@ {-# LANGUAGE ApplicativeDo #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -22,6 +23,12 @@ data DirectoryOpts = DirectoryOpts adminUsers :: [KnownContact], superUsers :: [KnownContact], ownersGroup :: Maybe KnownGroup, + blockedWordsFile :: Maybe FilePath, + blockedFragmentsFile :: Maybe FilePath, + blockedExtensionRules :: Maybe FilePath, + nameSpellingFile :: Maybe FilePath, + profileNameLimit :: Int, + captchaGenerator :: Maybe FilePath, directoryLog :: Maybe FilePath, serviceName :: T.Text, runCLI :: Bool, @@ -55,6 +62,49 @@ directoryOpts appDir defaultDbName = do <> metavar "OWNERS_GROUP" <> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically" ) + blockedWordsFile <- + optional $ + strOption + ( long "blocked-words-file" + <> metavar "BLOCKED_WORDS_FILE" + <> help "File with the basic forms of words not allowed in profiles" + ) + blockedFragmentsFile <- + optional $ + strOption + ( long "blocked-fragments-file" + <> metavar "BLOCKED_WORDS_FILE" + <> help "File with the basic forms of word fragments not allowed in profiles" + ) + blockedExtensionRules <- + optional $ + strOption + ( long "blocked-extenstion-rules" + <> metavar "BLOCKED_EXTENSION_RULES" + <> help "Substitions to extend the list of blocked words" + ) + nameSpellingFile <- + optional $ + strOption + ( long "name-spelling-file" + <> metavar "NAME_SPELLING_FILE" + <> help "File with the character substitions to match in profile names" + ) + profileNameLimit <- + option + auto + ( long "profile-name-limit" + <> metavar "PROFILE_NAME_LIMIT" + <> help "Max length of profile name that will be allowed to connect and to join groups" + <> value maxBound + ) + captchaGenerator <- + optional $ + strOption + ( long "captcha-generator" + <> metavar "CAPTCHA_GENERATOR" + <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" + ) directoryLog <- Just <$> strOption @@ -80,6 +130,12 @@ directoryOpts appDir defaultDbName = do adminUsers, superUsers, ownersGroup, + blockedWordsFile, + blockedFragmentsFile, + blockedExtensionRules, + nameSpellingFile, + profileNameLimit, + captchaGenerator, directoryLog, serviceName = T.pack serviceName, runCLI, @@ -102,7 +158,6 @@ mkChatOpts :: DirectoryOpts -> ChatOpts mkChatOpts DirectoryOpts {coreOptions} = ChatOpts { coreOptions, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index ed51371be3..13510b4d2b 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -1,31 +1,45 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module Directory.Service ( welcomeGetOpts, directoryService, directoryServiceCLI, + newServiceState, + acceptMemberHook ) where import Control.Concurrent (forkIO) import Control.Concurrent.Async import Control.Concurrent.STM +import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad +import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Int (Int64) import Data.List (find, intercalate) +import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (diffUTCTime, getCurrentTime) +import qualified Data.Text.IO as T +import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) +import Directory.BlockedWords +import Directory.Captcha import Directory.Events import Directory.Options import Directory.Search @@ -34,20 +48,29 @@ import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core +import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Store.Direct (getContact) +import Simplex.Chat.Store.Groups (getGroupInfo, getGroupLink, getGroupSummary, setGroupCustomData) +import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Terminal (terminalChatConfig) 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.Chat.View (serializeChatError, 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) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) import System.Directory (getAppUserDataDirectory) +import System.Process (readProcess) data GroupProfileUpdate = GPNoServiceLink | GPServiceLinkAdded | GPServiceLinkRemoved | GPHasServiceLink | GPServiceLinkError @@ -64,13 +87,32 @@ data GroupRolesStatus deriving (Eq) data ServiceState = ServiceState - { searchRequests :: TMap ContactId SearchRequest + { searchRequests :: TMap ContactId SearchRequest, + blockedWordsCfg :: BlockedWordsConfig, + pendingCaptchas :: TMap GroupMemberId PendingCaptcha } -newServiceState :: IO ServiceState -newServiceState = do +data PendingCaptcha = PendingCaptcha + { captchaText :: Text, + sentAt :: UTCTime, + attempts :: Int + } + +captchaLength :: Int +captchaLength = 7 + +maxCaptchaAttempts :: Int +maxCaptchaAttempts = 5 + +captchaTTL :: NominalDiffTime +captchaTTL = 600 -- 10 minutes + +newServiceState :: DirectoryOpts -> IO ServiceState +newServiceState opts = do searchRequests <- TM.emptyIO - pure ServiceState {searchRequests} + blockedWordsCfg <- readBlockedWordsConfig opts + pendingCaptchas <- TM.emptyIO + pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas} welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do @@ -94,11 +136,12 @@ welcomeGetOpts = do directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () directoryServiceCLI st opts = do - env <- newServiceState + env <- newServiceState opts eventQ <- newTQueueIO let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) + chatHooks = defaultChatHooks {eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} race_ - (simplexChatCLI' terminalChatConfig {chatHooks = defaultChatHooks {eventHook}} (mkChatOpts opts) Nothing) + (simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing) (processEvents eventQ env) where processEvents eventQ env = forever $ do @@ -106,21 +149,63 @@ directoryServiceCLI st opts = do u_ <- readTVarIO (currentUser cc) forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp -directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () -directoryService st opts@DirectoryOpts {testing} user cc = do +directoryService :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> IO () +directoryService st opts@DirectoryOpts {testing} env user cc = do initializeBotAddress' (not testing) cc - env <- newServiceState race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc + (_, resp) <- atomically . readTBQueue $ outputQ cc directoryServiceEvent st opts env user cc resp -directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () -directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} ServiceState {searchRequests} user@User {userId} cc event = +acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) +acceptMemberHook + DirectoryOpts {profileNameLimit} + ServiceState {blockedWordsCfg} + g + GroupLinkInfo {memberRole} + Profile {displayName, image = img} = runExceptT $ do + let a = groupMemberAcceptance g + when (useMemberFilter img $ rejectNames a) checkName + pure $ + if + | useMemberFilter img (passCaptcha a) -> (GAPendingApproval, GRMember) + | useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver) + | otherwise -> (GAAccepted, memberRole) + where + checkName :: ExceptT GroupRejectionReason IO () + checkName + | T.length displayName > profileNameLimit = throwError GRRLongName + | otherwise = do + when (hasBlockedFragments blockedWordsCfg displayName) $ throwError GRRBlockedName + when (hasBlockedWords blockedWordsCfg displayName) $ throwError GRRBlockedName + +groupMemberAcceptance :: GroupInfo -> DirectoryMemberAcceptance +groupMemberAcceptance GroupInfo {customData} = memberAcceptance $ fromCustomData customData + +useMemberFilter :: Maybe ImageData -> Maybe ProfileCondition -> Bool +useMemberFilter img_ = \case + Just PCAll -> True + Just PCNoImage -> maybe True (\(ImageData i) -> i == "") img_ + Nothing -> False + +readBlockedWordsConfig :: DirectoryOpts -> IO BlockedWordsConfig +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 + 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 -> Either ChatError ChatEvent -> IO () +directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case 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 DEServiceRoleChanged g role -> deServiceRoleChanged g role DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g @@ -146,7 +231,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId withGroupReg GroupInfo {groupId, localDisplayName} err action = do - atomically (getGroupReg st groupId) >>= \case + getGroupReg st groupId >>= \case Just gr -> action gr Nothing -> logError $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId groupInfoText GroupProfile {displayName = n, fullName = fn, description = d} = @@ -164,31 +249,39 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own getGroups_ :: Maybe Text -> IO (Maybe [(GroupInfo, GroupSummary)]) getGroups_ search_ = sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case - CRGroupsList {groups} -> pure $ Just groups + Right CRGroupsList {groups} -> pure $ Just groups _ -> pure Nothing getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup) 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 void $ addGroupReg st ct g GRSProposed r <- sendChatCmd cc $ APIJoinGroup groupId MFNone sendMessage cc ct $ case r of - CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" + Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" deContactConnected :: Contact -> IO () @@ -244,7 +337,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own $>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId) where resp = \case - CRGroupMemberInfo {member} -> Just member + Right CRGroupMemberInfo {member} -> Just member _ -> Nothing deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () @@ -255,16 +348,16 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own 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 + Right 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) - CRChatCmdError _ (ChatError e) -> case e of + notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> strEncodeTxt (simplexChatContact gLink) + Left (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." CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" @@ -272,78 +365,97 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own _ -> 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' + Right 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 @@ -356,10 +468,87 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own Just (Just msg) -> notifyOwner gr msg Just Nothing -> sendToApprove toGroup gr gaId + dePendingMember :: GroupInfo -> GroupMember -> IO () + dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do + s <- getCaptchaStr captchaLength "" + mc <- getCaptcha s + sentAt <- getCurrentTime + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1} + atomically $ TM.insert gmId captcha $ pendingCaptchas env + sendCaptcha mc + where + getCaptcha s = case captchaGenerator opts of + Nothing -> pure textMsg + Just script -> content <$> readProcess script [s] "" + where + textMsg = MCText $ T.pack s + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(quotedId, MCText noticeText), (Nothing, mc)] + gmId = groupMemberId' m + + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () + approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do + gli_ <- join <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) + let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ + gmId = groupMemberId' m + sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case + Right CRMemberAccepted {member} -> do + atomically $ TM.delete gmId $ pendingCaptchas env + if memberStatus member == GSMemPendingReview + then logInfo $ "Member " <> viewName displayName <> " accepted and pending review, group " <> tshow groupId <> ":" <> viewGroupName g + else logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected accept member response: " <> tshow r + + dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () + dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText + | memberRequiresCaptcha a m = do + ts <- getCurrentTime + atomically (TM.lookup (groupMemberId' m) $ pendingCaptchas env) >>= \case + Just PendingCaptcha {captchaText, sentAt, attempts} + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 + | matchCaptchaStr captchaText msgText -> do + sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just $ groupMemberId' m)) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + approvePendingMember a g m + | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts + | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts + Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + rejectPendingMember rjctNotice = do + let gmId = groupMemberId' m + sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case + Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + atomically $ TM.delete gmId $ pendingCaptchas env + logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired = "Captcha expired, please try again." + wrongCaptcha attempts + | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." + | otherwise = "Incorrect text, please try again." + noCaptcha = "Unexpected message, please try again." + tooManyAttempts = "Too many failed attempts, you can't join group." + + memberRequiresCaptcha :: DirectoryMemberAcceptance -> GroupMember -> Bool + memberRequiresCaptcha a GroupMember {memberProfile = LocalProfile {image}} = + useMemberFilter image $ passCaptcha a + sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do - ct_ <- getContact cc dbContactId - gr_ <- getGroupAndSummary cc dbGroupId + ct_ <- getContact' cc user dbContactId + gr_ <- getGroupAndSummary cc user dbGroupId let membersStr = maybe "" (\(_, s) -> "_" <> tshow (currentMembers s) <> " members_\n") gr_ text = maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ @@ -460,7 +649,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own 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 " @@ -471,7 +660,16 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own <> " 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 @@ -501,41 +699,81 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own _ -> processInvitation ct g _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." DCListUserGroups -> - atomically (getUserGroupRegs st $ contactId' ct) >>= \grs -> do + getUserGroupRegs st (contactId' ct) >>= \grs -> do sendReply $ tshow (length grs) <> " registered group(s)" 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" - DCSetRole gId gName mRole -> - (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ - \GroupInfo {groupId, groupProfile = GroupProfile {displayName}} _gr -> do - gLink_ <- setGroupLinkRole cc groupId mRole - sendReply $ case gLink_ of - Nothing -> "Error: the initial member role for the group " <> displayName <> " was NOT upgated" - Just gLink -> - ("The initial member role for the group " <> displayName <> " is set to *" <> strEncodeTxt mRole <> "*\n\n") - <> ("*Please note*: it applies only to members joining via this link: " <> strEncodeTxt (simplexChatContact gLink)) + 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 (_, CCLink gLink _, _, mRole) -> do + let anotherRole = case mRole of GRObserver -> GRMember; _ -> GRObserver + sendReply $ + initialRole n mRole + <> ("Send */role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "* to change it.\n\n") + <> onlyViaLink gLink + Nothing -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Just mRole -> do + setGroupLinkRole cc g mRole >>= \case + Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink + Nothing -> sendReply $ "Error: the initial member role for the group " <> n <> " was NOT upgated." + where + initialRole n mRole = "The initial member role for the group " <> n <> " is set to *" <> strEncodeTxt mRole <> "*\n" + onlyViaLink gLink = "*Please note*: it applies only to members joining via this link: " <> strEncodeTxt (simplexChatContact gLink) + DCGroupFilter gId gName_ acceptance_ -> + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do + let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g + a = groupMemberAcceptance g + case acceptance_ of + Just a' | a /= a' -> do + let d = toCustomData $ DirectoryGroupData a' + withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case + Just () -> sendSettigns n a' " set to" + Nothing -> sendReply $ "Error changing spam filter settings for group " <> n + _ -> sendSettigns n a "" + where + sendSettigns n a setTo = + sendReply $ + T.unlines + [ "Spam filter settings for group " <> n <> setTo <> ":", + "- reject long/inappropriate names: " <> showCondition (rejectNames a), + "- pass captcha to join: " <> showCondition (passCaptcha a), + -- "- make observer: " <> showCondition (makeObserver a) <> (if isJust (makeObserver a) then "" else " (use default set with /role command)"), + "", + -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", + -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." + "Use */filter " <> tshow gId <> " [name] [captcha]* to enable and */filter " <> tshow gId <> " off* to disable filter." + ] + showCondition = \case + Nothing -> "_disabled_" + Just PCAll -> "_enabled_" + Just PCNoImage -> "_enabled for profiles without image_" DCUnknownCommand -> sendReply "Unknown command" DCCommandError tag -> sendReply $ "Command error: " <> tshow tag where knownCt = knownContact ct isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers - withUserGroupReg ugrId gName action = - atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case + withUserGroupReg ugrId = withUserGroupReg_ ugrId . Just + withUserGroupReg_ ugrId gName_ action = + getUserGroupReg st (contactId' ct) ugrId >>= \case Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" Just gr@GroupReg {dbGroupId} -> do - getGroup cc dbGroupId >>= \case + getGroup cc user dbGroupId >>= \case Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | displayName == gName -> action g gr + | maybe True (displayName ==) gName_ -> action g gr | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName sendReply = mkSendReply ct ciId withFoundListedGroups s_ action = getGroups_ s_ >>= \case - Just groups -> atomically (filterListedGroups st groups) >>= action + Just groups -> filterListedGroups st groups >>= action Nothing -> sendReply "Error: getGroups. Please notify the developers." sendSearchResults s = \case [] -> sendReply "No groups found" @@ -543,18 +781,18 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own let gs' = takeTop searchResults gs moreGroups = length gs - length gs' more = if moreGroups > 0 then ", sending top " <> tshow (length gs') else "" - sendReply $ "Found " <> tshow (length gs) <> " group(s)" <> more <> "." + reply = "Found " <> tshow (length gs) <> " group(s)" <> more <> "." updateSearchRequest (STSearch s) $ groupIds gs' - sendFoundGroups gs' moreGroups + sendFoundGroups reply gs' moreGroups sendAllGroups takeFirst sortName searchType = \case [] -> sendReply "No groups listed" gs -> do let gs' = takeFirst searchResults gs moreGroups = length gs - length gs' more = if moreGroups > 0 then ", sending " <> sortName <> " " <> tshow (length gs') else "" - sendReply $ tshow (length gs) <> " group(s) listed" <> more <> "." + reply = tshow (length gs) <> " group(s) listed" <> more <> "." updateSearchRequest searchType $ groupIds gs' - sendFoundGroups gs' moreGroups + sendFoundGroups reply gs' moreGroups sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case [] -> do sendReply "Sorry, no more groups" @@ -563,33 +801,31 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own let gs' = takeFirst searchResults $ filterNotSent sentGroups gs sentGroups' = sentGroups <> groupIds gs' moreGroups = length gs - S.size sentGroups' - sendReply $ "Sending " <> tshow (length gs') <> " more group(s)." + reply = "Sending " <> tshow (length gs') <> " more group(s)." updateSearchRequest searchType sentGroups' - sendFoundGroups gs' moreGroups + sendFoundGroups reply gs' moreGroups updateSearchRequest :: SearchType -> Set GroupId -> IO () updateSearchRequest searchType sentGroups = do searchTime <- getCurrentTime let search = SearchRequest {searchType, searchTime, sentGroups} atomically $ TM.insert (contactId' ct) search searchRequests - sendFoundGroups gs moreGroups = - void . forkIO $ do - forM_ gs $ - \(GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" - showId = if isAdmin then tshow groupId <> ". " else "" - text = showId <> groupInfoText p <> "\n" <> membersStr - msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - when (moreGroups > 0) $ - sendComposedMessage cc ct Nothing $ - MCText $ - "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)." + sendFoundGroups reply gs moreGroups = + void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs + where + msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] + replyMsg = (Just ciId, MCText reply) + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) = + let membersStr = "_" <> tshow currentMembers <> " members_" + showId = if isAdmin then tshow groupId <> ". " else "" + text = showId <> groupInfoText p <> "\n" <> membersStr + in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) + moreMsg = (Nothing, MCText $ "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s).") deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of DCApproveGroup {groupId, displayName = n, groupApprovalId} -> - withGroupAndReg sendReply groupId n $ \g gr -> + withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId} -> readTVarIO (groupRegStatus gr) >>= \case GRSPendingApproval gaId | gaId == groupApprovalId -> do @@ -601,7 +837,13 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own Just GRSOk -> do setGroupStatus st gr GRSActive let approved = "The group " <> userGroupReference' gr n <> " is approved" - notifyOwner gr $ approved <> " and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." + notifyOwner gr $ + (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" + <> "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 @@ -651,18 +893,21 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ _ -> sendChatCmd cc (APIGetGroupLink groupId) >>= \case - CRGroupLink {connReqContact, memberRole} -> + Right 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 _)) -> + Left (ChatErrorStore (SEGroupLinkNotFound _)) -> sendReply $ "The group " <> groupRef <> " has no public link." - r -> do + Right r -> do ts <- getCurrentTime tz <- getCurrentTimeZone - let resp = T.pack $ serializeChatResponse (Nothing, Just user) ts tz Nothing r + let resp = T.pack $ serializeChatResponse (Nothing, Just user) (config cc) ts tz Nothing r + sendReply $ "Unexpected error:\n" <> resp + Left e -> do + let resp = T.pack $ serializeChatError True (config cc) e sendReply $ "Unexpected error:\n" <> resp DCSendToGroupOwner groupId gName msg -> do let groupRef = groupReference' groupId gName @@ -682,6 +927,8 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own sendReply $ "you" <> invited Left err -> sendReply err Nothing -> sendReply "owners' group is not specified" + -- DCAddBlockedWord _word -> pure () + -- DCRemoveBlockedWord _word -> pure () DCCommandError tag -> sendReply $ "Command error: " <> tshow tag | otherwise = sendReply "You are not allowed to use this command" where @@ -696,17 +943,17 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own else pure groups sendReply $ tshow (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> tshow count else "") void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do - ct_ <- getContact cc dbContactId + ct_ <- getContact' cc user dbContactId let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ sendGroupInfo ct gr dbGroupId $ Just ownerStr inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = sendChatCmd cc (APIListMembers ogId) >>= \case - CRGroupMembers _ (Group _ ms) + Right (CRGroupMembers _ (Group _ ms)) | alreadyMember ms -> cont $ Left "Owner is already a member of owners' group" | otherwise -> do sendChatCmd cc (APIAddMember ogId ctId GRMember) >>= \case - CRSentGroupInvitation {} -> do + Right CRSentGroupInvitation {} -> do printLog cc CLLInfo $ "invited contact ID " <> show ctId <> " to owners' group" cont $ Right () r -> contErr r @@ -718,7 +965,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own putStrLn $ T.unpack err cont $ Left err groupOwnerInfo groupRef dbContactId = do - owner_ <- getContact cc dbContactId + owner_ <- getContact' cc user dbContactId let ownerInfo = "the owner of the group " <> groupRef ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " pure $ maybe "" ownerName owner_ <> ownerInfo @@ -727,10 +974,13 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own deSuperUserCommand ct ciId cmd | knownContact ct `elem` superUsers = case cmd of DCExecuteCommand cmdStr -> - sendChatCmdStr cc cmdStr >>= \r -> do - ts <- getCurrentTime - tz <- getCurrentTimeZone - sendReply $ T.pack $ serializeChatResponse (Nothing, Just user) ts tz Nothing r + sendChatCmdStr cc cmdStr >>= \case + Right r -> do + ts <- getCurrentTime + tz <- getCurrentTimeZone + sendReply $ T.pack $ serializeChatResponse (Nothing, Just user) (config cc) ts tz Nothing r + Left e -> + sendReply $ T.pack $ serializeChatError True (config cc) e DCCommandError tag -> sendReply $ "Command error: " <> tshow tag | otherwise = sendReply "You are not allowed to use this command" where @@ -743,12 +993,15 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText withGroupAndReg :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () - withGroupAndReg sendReply gId gName action = - getGroup cc gId >>= \case + withGroupAndReg sendReply gId = withGroupAndReg_ sendReply gId . Just + + withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () + withGroupAndReg_ sendReply gId gName_ action = + getGroup cc user gId >>= \case Nothing -> sendReply $ "Group ID " <> tshow gId <> " not found (getGroup)" Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | displayName == gName -> - atomically (getGroupReg st gId) >>= \case + | maybe False (displayName ==) gName_ -> + getGroupReg st gId >>= \case Nothing -> sendReply $ "Registration for group ID " <> tshow gId <> " not found (getGroupReg)" Just gr -> action g gr | otherwise -> @@ -758,7 +1011,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do grStatus <- readTVarIO $ groupRegStatus gr let statusStr = "Status: " <> groupRegStatusText grStatus - getGroupAndSummary cc dbGroupId >>= \case + getGroupAndSummary cc user dbGroupId >>= \case Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do let membersStr = "_" <> tshow currentMembers <> " members_" text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr] @@ -768,34 +1021,39 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] sendComposedMessage cc ct Nothing $ MCText text -getContact :: ChatController -> ContactId -> IO (Maybe Contact) -getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) Nothing (CPLast 0) Nothing) - where - resp :: ChatResponse -> Maybe Contact - resp = \case - CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) _ -> Just ct - _ -> Nothing +getContact' :: ChatController -> User -> ContactId -> IO (Maybe Contact) +getContact' cc user ctId = withDB "getContact" cc $ \db -> getContact db (vr cc) user ctId -getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo) -getGroup cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId) - where - resp :: ChatResponse -> Maybe GroupInfo - resp = \case - CRGroupInfo {groupInfo} -> Just groupInfo - _ -> Nothing +getGroup :: ChatController -> User -> GroupId -> IO (Maybe GroupInfo) +getGroup cc user gId = withDB "getGroupInfo" cc $ \db -> getGroupInfo db (vr cc) user gId -getGroupAndSummary :: ChatController -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) -getGroupAndSummary cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId) - where - resp = \case - CRGroupInfo {groupInfo, groupSummary} -> Just (groupInfo, groupSummary) - _ -> Nothing +withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) +withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a -setGroupLinkRole :: ChatController -> GroupId -> GroupMemberRole -> IO (Maybe ConnReqContact) -setGroupLinkRole cc gId mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole gId mRole) +withDB :: Text -> ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) +withDB cxt ChatController {chatStore} action = do + r_ :: Either ChatError a <- withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors + case r_ of + Right r -> pure $ Just r + Left e -> Nothing <$ logError ("Database error: " <> cxt <> " " <> tshow e) + +getGroupAndSummary :: ChatController -> User -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) +getGroupAndSummary cc user gId = + withDB "getGroupAndSummary" cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) + +vr :: ChatController -> VersionRangeChat +vr ChatController {config = ChatConfig {chatVRange}} = chatVRange +{-# INLINE vr #-} + +getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, CreatedLinkContact, GroupLinkId, GroupMemberRole)) +getGroupLinkRole cc user gInfo = + withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo + +setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe ConnReqContact) +setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where resp = \case - CRGroupLink _ _ gLink _ -> Just gLink + Right (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 cecb253e8d..628419ea1d 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -3,6 +3,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} module Directory.Store ( DirectoryStore (..), @@ -10,6 +11,9 @@ module Directory.Store GroupRegStatus (..), UserGroupRegId, GroupApprovalId, + DirectoryGroupData (..), + DirectoryMemberAcceptance (..), + ProfileCondition (..), restoreDirectoryStore, addGroupReg, delGroupReg, @@ -21,25 +25,36 @@ module Directory.Store filterListedGroups, groupRegStatusText, pendingApproval, + groupRemoved, + fromCustomData, + toCustomData, + noJoinFilter, + basicJoinFilter, + moderateJoinFilter, + strongJoinFilter ) where import Control.Concurrent.STM import Control.Monad +import Data.Aeson ((.=), (.:)) +import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Composition ((.:)) import Data.Int (Int64) import Data.List (find, foldl', sortOn) import Data.Map (Map) import qualified Data.Map.Strict as M -import Data.Maybe (isJust) +import Data.Maybe (fromMaybe, isJust) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import Simplex.Chat.Types import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) import Simplex.Messaging.Util (ifM) import System.Directory (doesFileExist, renameFile) import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile) @@ -67,6 +82,51 @@ data GroupRegData = GroupRegData groupRegStatus_ :: GroupRegStatus } +data DirectoryGroupData = DirectoryGroupData + { memberAcceptance :: DirectoryMemberAcceptance + } + +-- these filters are applied in the order of fields, depending on ProfileCondition: +-- Nothing - do not apply +-- Just +-- PCAll - apply to all profiles +-- PCNoImage - apply to profiles without images +data DirectoryMemberAcceptance = DirectoryMemberAcceptance + { rejectNames :: Maybe ProfileCondition, -- reject long names and names with profanity + passCaptcha :: Maybe ProfileCondition, -- run captcha challenge with joining members + makeObserver :: Maybe ProfileCondition -- the role assigned in the end, after captcha challenge + } + deriving (Eq, Show) + +data ProfileCondition = PCAll | PCNoImage deriving (Eq, Show) + +noJoinFilter :: DirectoryMemberAcceptance +noJoinFilter = DirectoryMemberAcceptance Nothing Nothing Nothing + +basicJoinFilter :: DirectoryMemberAcceptance +basicJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCNoImage, + passCaptcha = Nothing, + makeObserver = Nothing + } + +moderateJoinFilter :: DirectoryMemberAcceptance +moderateJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCAll, + passCaptcha = Just PCNoImage, + makeObserver = Nothing + } + +strongJoinFilter :: DirectoryMemberAcceptance +strongJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCAll, + passCaptcha = Just PCAll, + makeObserver = Nothing + } + type UserGroupRegId = Int64 type GroupApprovalId = Int64 @@ -80,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 @@ -104,18 +170,34 @@ grDirectoryStatus = \case GRSActive -> DSListed GRSSuspended -> DSReserved GRSSuspendedBadRoles -> DSReserved + GRSRemoved -> DSRemoved _ -> DSRegistered +$(JQ.deriveJSON (enumJSON $ dropPrefix "PC") ''ProfileCondition) + +$(JQ.deriveJSON defaultJSON ''DirectoryMemberAcceptance) + +$(JQ.deriveJSON defaultJSON ''DirectoryGroupData) + +fromCustomData :: Maybe CustomData -> DirectoryGroupData +fromCustomData cd_ = + let memberAcceptance = fromMaybe noJoinFilter $ cd_ >>= \(CustomData o) -> JT.parseMaybe (.: "memberAcceptance") o + in DirectoryGroupData {memberAcceptance} + +toCustomData :: DirectoryGroupData -> CustomData +toCustomData DirectoryGroupData {memberAcceptance} = + CustomData $ JM.fromList ["memberAcceptance" .= memberAcceptance] + addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId addGroupReg st ct GroupInfo {groupId} grStatus = do - grData <- atomically addGroupReg_ + grData <- addGroupReg_ logGCreate st grData pure $ userGroupRegId_ grData where addGroupReg_ = do let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus} gr <- dataToGroupReg grData - stateTVar (groupRegs st) $ \grs -> + atomically $ stateTVar (groupRegs st) $ \grs -> let ugrId = 1 + foldl' maxUgrId 0 grs grData' = grData {userGroupRegId_ = ugrId} gr' = gr {userGroupRegId = ugrId} @@ -126,10 +208,11 @@ 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) + atomically $ modifyTVar' (groupRegs st) $ filter ((gId /=) . dbGroupId) setGroupStatus :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO () setGroupStatus st gr grStatus = do @@ -142,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 @@ -149,18 +233,18 @@ setGroupRegOwner st gr owner = do logGUpdateOwner st (dbGroupId gr) memberId atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId) -getGroupReg :: DirectoryStore -> GroupId -> STM (Maybe GroupReg) -getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVar (groupRegs st) +getGroupReg :: DirectoryStore -> GroupId -> IO (Maybe GroupReg) +getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVarIO (groupRegs st) -getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> STM (Maybe GroupReg) -getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVar (groupRegs st) +getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> IO (Maybe GroupReg) +getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVarIO (groupRegs st) -getUserGroupRegs :: DirectoryStore -> ContactId -> STM [GroupReg] -getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVar (groupRegs st) +getUserGroupRegs :: DirectoryStore -> ContactId -> IO [GroupReg] +getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVarIO (groupRegs st) -filterListedGroups :: DirectoryStore -> [(GroupInfo, GroupSummary)] -> STM [(GroupInfo, GroupSummary)] +filterListedGroups :: DirectoryStore -> [(GroupInfo, GroupSummary)] -> IO [(GroupInfo, GroupSummary)] filterListedGroups st gs = do - lgs <- readTVar $ listedGroups st + lgs <- readTVarIO $ listedGroups st pure $ filter (\(GroupInfo {groupId}, _) -> groupId `S.member` lgs) gs listGroup :: DirectoryStore -> GroupId -> STM () @@ -200,10 +284,10 @@ logGDelete :: DirectoryStore -> GroupId -> IO () logGDelete st = logDLR st . GRDelete logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO () -logGUpdateStatus st = logDLR st .: GRUpdateStatus +logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO () -logGUpdateOwner st = logDLR st .: GRUpdateOwner +logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId instance StrEncoding DLRTag where strEncode = \case @@ -271,10 +355,10 @@ instance StrEncoding GroupRegStatus where "removed" -> pure GRSRemoved _ -> fail "invalid GroupRegStatus" -dataToGroupReg :: GroupRegData -> STM GroupReg +dataToGroupReg :: GroupRegData -> IO GroupReg dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = do - dbOwnerMemberId <- newTVar dbOwnerMemberId_ - groupRegStatus <- newTVar groupRegStatus_ + dbOwnerMemberId <- newTVarIO dbOwnerMemberId_ + groupRegStatus <- newTVarIO groupRegStatus_ pure GroupReg { dbGroupId = dbGroupId_, @@ -286,10 +370,9 @@ dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerM restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore restoreDirectoryStore = \case - Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= new . Just) - Nothing -> new Nothing + Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= newDirectoryStore . Just) + Nothing -> newDirectoryStore Nothing where - new = atomically . newDirectoryStore newFile f = do h <- openFile f WriteMode hSetBuffering h LineBuffering @@ -298,15 +381,15 @@ restoreDirectoryStore = \case grs <- readDirectoryData f renameFile f (f <> ".bak") h <- writeDirectoryData f grs -- compact - atomically $ mkDirectoryStore h grs + mkDirectoryStore h grs emptyStoreData :: ([GroupReg], Set GroupId, Set GroupId) emptyStoreData = ([], S.empty, S.empty) -newDirectoryStore :: Maybe Handle -> STM DirectoryStore +newDirectoryStore :: Maybe Handle -> IO DirectoryStore newDirectoryStore = (`mkDirectoryStore_` emptyStoreData) -mkDirectoryStore :: Handle -> [GroupRegData] -> STM DirectoryStore +mkDirectoryStore :: Handle -> [GroupRegData] -> IO DirectoryStore mkDirectoryStore h groups = foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h) where @@ -317,12 +400,13 @@ 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) -> STM DirectoryStore +mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> IO DirectoryStore mkDirectoryStore_ h (grs, listed, reserved) = do - groupRegs <- newTVar grs - listedGroups <- newTVar listed - reservedGroups <- newTVar reserved + groupRegs <- newTVarIO grs + listedGroups <- newTVarIO listed + reservedGroups <- newTVarIO reserved pure DirectoryStore {groupRegs, listedGroups, reservedGroups, directoryLogFile = h} readDirectoryData :: FilePath -> IO [GroupRegData] diff --git a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md index 13a514c175..55158130f8 100644 --- a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md +++ b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md @@ -164,7 +164,7 @@ The main objective here is to establish the framework for comparing the security 2 Post-quantum cryptography is available in beta version, as opt-in only for direct conversations. See below how it will be rolled-out further. Some columns are marked with a yellow checkmark: -- when messages are padded, but not to a fixed size. +- when messages are padded, but not to a fixed size (Briar pads messages to the size rounded up to 1024 bytes, Signal - to 160 bytes). - when repudiation does not include client-server connection. In case of Cwtch it appears that the presence of cryptographic signatures compromises repudiation (deniability), but it needs to be clarified. - when 2-factor key exchange is optional (via security code verification). - when post-quantum cryptography is only added to the initial key agreement and does not protect break-in recovery. diff --git a/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md new file mode 100644 index 0000000000..f1aa3a243f --- /dev/null +++ b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md @@ -0,0 +1,188 @@ +--- +layout: layouts/article.html +title: "SimpleX Chat v6.3: new user experience and safety in public groups" +date: 2025-03-08 +previewBody: blog_previews/20250308.html +image: images/20250308-captcha.png +imageBottom: true +permalink: "/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html" +--- + +# SimpleX Chat v6.3: new user experience and safety in public groups + +**Published:** Mar 8, 2025 + +**What's new in v6.3**: +- [preventing spam and abuse in public groups](#preventing-spam-and-abuse-in-public-groups). +- [group improvements](#group-improvements): [mention other members](#mention-other-members-and-get-notified-when-mentioned), [improved performance](#better-group-performance). +- [better chat navigation](#better-chat-navigation): [organize chats into lists](#organize-chats-into-lists) and [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages). +- [privacy and security improvements](#privacy-and-security-improvements): [chat retention period](#set-message-retention-period-in-chats) and [private media file names](#private-media-file-names). + +Also, we added Catalan interface language to Android and desktop apps, thanks to [our users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat). + +The last but not the least - [server builds are now reproducible](#reproducible-server-builds). + +## What's new in v6.3 + +## Preventing spam and abuse in public groups + +[We wrote before](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md): as the network grows, it becomes more attractive to attackers. This release adds several features that reduce the possibility of attacks and abuse. + +### Spam in groups that are listed in our group directory + +There is no built-in group discovery in SimpleX Chat apps. Instead, we offer an experimental chat bot that allows to submit and to discover public groups. Not so long ago, spammers started sending messages via bots attempting to disrupt these groups. + +We released several changes to the groups directory to protect from spam attacks. + +**Optional captcha verification** + + + +Group owners can enable the requirement to pass captcha challenge before joining the group. Captcha is generated in the directory bot itself, without any 3rd party servers, and is sent to the joining member. The new member must reply with the text in the image to be accepted to the group. While not a perfect protection, this basic measure complicates programming automatic bots to join public groups. It also provides a foundation to implement "knocking" - a conversation with dedicated group admins prior to joining the group. We plan to release support for knocking in March. + +**Profanity filter for member names** + +While group settings support giving all joining member an "observer" role - that is, without the right to send messages - the attackers tried spaming groups by joining and leaving. We added an optional filter for member names that group owners can enable for groups listed in directory - if a member name contains profanity, they will be rejected. Further improvements will be released in March as well. + +The current SimpleX directory chatbot is a hybrid of [future chat relays](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md#can-large-groups-scale) (a.k.a. super-peers) we are now developing to support large groups, and of a directory service that will be embedded in the app UI later this year, allowing to search and to discover public groups. Anybody is able to run their own directory bots now, and there will be possibility to use third party directories via the app UI in the future too. + +Read more about [SimpleX group directory](../docs/DIRECTORY.md), how to submit your groups, and which groups we now accept. Currently we accept groups related to a limited list of topics that will be expanded once we have better moderation functionality for the groups. + +### More power to group owners and moderators + +This release includes two new features to help group moderators. + + + +**Private reports** + +Group members can privately bring to group moderators attention specific messages and members, even if the group does not allow direct messages. The simply need to choose report in the message context menu and choose the report reason. This report will be visible to all group owners and moderators, but not to other members. + +Group moderators can see all member reports in a separate view, and quickly find the problematic messages, making moderation much easier in public groups. These reports are private to groups, they are not sent to server operators. + +Please note: in the groups listed in our directory, the directory bot acts as admin, so it will receive all reports as well. + +**Acting on multiple members at once** + +When attackers come, they often use multiple profiles. This version allows selecting multiple members at once and perform these actions on all selected members: +- switch members role between "observer" and "member". +- block and unblock members - this is a "shadow" block, so when you block multiple members who you believe are attackers, their messages will be blocked for all other members but not for them. +- remove members from the group. + +The next version will also allow to remove members together with all messages they sent - for example, if a spam bot joined and sent a lot of spam, but nothing of value. + +## Group improvements + +### Mention other members and get notified when mentioned + + + +This feature allows you to mention other members in the group in the usual way - type `@` character, and choose the member you want to mention from the menu. Even that there is no user accounts and persistent identities we made it work by referencing members by their random group ID that is also used for replies and all other interactions in the group. + +You can also now switch message notifications in the group to "mentions only" mode. You will be notified only when you are mentioned in a message, or when somebody replies to your message. Simply choose "Mute" in the context menu of the group in the list of chats to switch group notifications to "mentions only" mode. After that you can choose "Mute all" to disable all notifications, including mentions. + +### Better group performance + +**Send messages faster** + +We didn't reduce the required network traffic to send messages to large groups yet - your client still has to send message to each member individually. But we redesigned the process of sending a message, reducing temporary storage required to schedule the message for delivery by about 100x. This creates a significant storage saving - e.g, to send one message to a group of 1,000 members previously required ~20Mb, and now it is reduced to ~200kb. It also reduces the time and battery used to send a message. + +**Faster group deletion** + +When you leave the group, the app preserves a copy of all your communications in the group. You can choose to keep it or to delete it completely. This final group deletion was very slow prior to this release - depending on the number of groups on your device it could sometimes take several minutes. + +This release solved this problem – the time it takes to delete the group is reduced to seconds, and even in cases when the app is terminated half-way, it either rolls back or completes, but it cannot leave the group in a partially deleted state. It improves both user experience and privacy, as gives you better control over your data. + +## Better chat navigation + +### Organize chats into lists + + + +It is a common feature in many messengers – it helps organizing your conversations. + +The lists also show a blue mark when any chat in the list has new messages. + +There are several preset lists: contacts, groups, private notes, business chats, favourite chats and also groups with member reports - the last list is automatically shown if members of any groups where you are the moderator or the owner sent private reports, until these reports are acted on or archived. + +### Jump to found and forwarded messages + +This version allows to quickly navigate from message in the search results to the point in the conversation when it was sent. + +You can also navigate from the forwarded message (or from the message saved to private notes) to the original message in the chat where it was forwarded or saved from. + +## Privacy and security improvements + +### Set message retention period in chats + +Before this version, you could enable message retention period for all chats in your profile. While helpful in some cases, many of us have conversations that we want to keep for a long time, and some other conversations that we want to remove quicker. + +This version allows it - you can set different retention periods in different conversations. It can be 1 day, 1 week, 1 month or 1 year. We may allow custom retention time in the future. + +### Private media file names + +Previously there were scenarios when original media file names were preserved - e.g., when sending a video file or when forwarding any media file. The latter problem was worse, as media file name is generated automatically, and includes timestamp. So the same name could have been used to correlate files between conversations, as one of our users pointed out. + +This version fixes this problem - media file name is now changed when forwarding it to match the time of forwarding, so no additional metadata is revealed. + +Please also note: +- the apps remove metadata from all static images, +- iOS app removes metadata from videos, but android and desktop apps do not do it yet, +- animated images are sent as is, +- other file types are sent as is, and their names are left unchanged - we believe that for ordinary files their name is part of their content. + +We plan further improvements to reduce metadata in files in the near future – please let us know what you believe is the most important to reduce first. + +## Reproducible server builds + +Starting from v6.3 server releases are reproducible! + +**Why it is important** + +With reproducible builds anybody can build servers from our code following the same process, and the build would produce identical binaries. + +This also allows us to sign releases, as we reproduce GitHub builds ourselves and by signing them we attest that our builds resulted in identical binaries. + +**How to reproduce builds** + +You can reproduce our builds on Linux with x86 CPU in docker container - please follow the instructions [here](../docs/SERVER.md#reproduce-builds). + +We are looking for support from open-source contributors or security researchers who would also reproduce and sign our releases. + +**How to verify release signature** + +Please see the instructions [here](../docs/SERVER.md#verifying-server-binaries). + +## SimpleX network + +Some links to answer the most common questions: + +[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers). + +[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users). + +[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-and-security-technical-details-and-limitations). + +[Frequently asked questions](../docs/FAQ.md). + +Please also see our [website](https://simplex.chat). + +## Please support us with your donations + +Huge *thank you* to everybody who donated to SimpleX Chat! + +Prioritizing users privacy and security, and also raising the investment, would have been impossible without your support and donations. + +Also, funding the work to transition the protocols to non-profit governance model would not have been possible without the donations we received from the users. + +Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. + +Your donations help us raise more funds — any amount, even the price of the cup of coffee, makes a big difference for us. + +See [this section](https://github.com/simplex-chat/simplex-chat/#please-support-us-with-your-donations) for the ways to donate. + +Thank you, + +Evgeny + +SimpleX Chat founder diff --git a/blog/README.md b/blog/README.md index 1432d95de5..00a84eca6c 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,25 @@ # Blog +Mar 3, 2025 [SimpleX Chat v6.3: new user experience and safety in public groups](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) + +What's new in v6.3: +- preventing spam and abuse in public groups. +- group improvements: mention other members and improved performance. +- better chat navigation: organize chats into lists and jump to found and forwarded messages. +- privacy and security improvements: chat retention period and private media file names. + +Also, we added Catalan interface language to Android and desktop apps, thanks to our users and Weblate. + +The last but not the least - server builds are now reproducible! + +-- + +Jan 14, 2025 [SimpleX network: large groups and privacy-preserving content moderation](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md) + +This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption. + +-- + Dec 10, 2024 [SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) - SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app to improve metadata privacy in SimpleX network. @@ -19,7 +39,7 @@ Nov 25, 2024 [Servers operated by Flux - true privacy and decentralization for a --- -Oct 14, 2024 [SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) +Oct 14, 2024 [SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) New security audit: Trail of Bits reviewed the cryptographic design of protocols used in SimpleX network and apps. diff --git a/blog/images/20240314-comparison.jpg b/blog/images/20240314-comparison.jpg index 5ff22be005..815922aa7a 100644 Binary files a/blog/images/20240314-comparison.jpg and b/blog/images/20240314-comparison.jpg differ diff --git a/blog/images/20250308-captcha.png b/blog/images/20250308-captcha.png new file mode 100644 index 0000000000..7f43b47bd1 Binary files /dev/null and b/blog/images/20250308-captcha.png differ diff --git a/blog/images/20250308-lists.png b/blog/images/20250308-lists.png new file mode 100644 index 0000000000..cd15adbcbc Binary files /dev/null and b/blog/images/20250308-lists.png differ diff --git a/blog/images/20250308-mentions.png b/blog/images/20250308-mentions.png new file mode 100644 index 0000000000..658c7e0011 Binary files /dev/null and b/blog/images/20250308-mentions.png differ diff --git a/blog/images/20250308-reports.png b/blog/images/20250308-reports.png new file mode 100644 index 0000000000..def06bf83e Binary files /dev/null and b/blog/images/20250308-reports.png differ diff --git a/cabal.project b/cabal.project index 9a7346c4d0..012eb62618 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: 23189753751dc52046865ce2d992335495020e91 + tag: 56ea2fdd56af5f5a5da41642486aa086d7371823 source-repository-package type: git diff --git a/docs/SERVER.md b/docs/SERVER.md index 9c3f2f619e..f45403be8a 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -7,35 +7,53 @@ revision: 12.10.2024 | Updated 12.10.2024 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md), [PL](/docs/lang/pl/SERVER.md) | -### Table of Contents +## Table of Contents -- [Quick start](#quick-start) -- [Detailed guide](#detailed-guide) - - [Overview](#overview) - - [Installation](#installation) - - [Configuration](#configuration) - - [Interactively](#interactively) - - [Via command line options](#via-command-line-options) - - [Further configuration](#further-configuration) - - [Server security](#server-security) - - [Initialization](#initialization) - - [Private keys](#private-keys) - - [Online certificate rotation](#online-certificate-rotation) - - [Tor: installation and configuration](#tor-installation-and-configuration) - - [Installation for onion address](#installation-for-onion-address) - - [SOCKS port for SMP PROXY](#socks-port-for-smp-proxy) - - [Server information page](#server-information-page) - - [Documentation](#documentation) - - [SMP server address](#smp-server-address) - - [Systemd commands](#systemd-commands) - - [Control port](#control-port) - - [Daily statistics](#daily-statistics) - - [Updating your SMP server](#updating-your-smp-server) - - [Configuring the app to use the server](#configuring-the-app-to-use-the-server) +- [Overview](#overview) +- [Quick start](#quick-start) with systemd service +- [Installation options](#installation-options) + - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) + - [docker container](#docker-container) + - [Linode marketplace](#linode-marketplace) +- [Verifying server binaries](#verifying-server-binaries) +- [Configuration](#configuration) + - [Interactively](#interactively) + - [Via command line options](#via-command-line-options) +- [Further configuration](#further-configuration) +- [Server security](#server-security) + - [Initialization](#initialization) + - [Private keys](#private-keys) + - [Online certificate rotation](#online-certificate-rotation) +- [Tor: installation and configuration](#tor-installation-and-configuration) + - [Installation for onion address](#installation-for-onion-address) + - [SOCKS port for SMP PROXY](#socks-port-for-smp-proxy) +- [Server information page](#server-information-page) +- [Documentation](#documentation) + - [SMP server address](#smp-server-address) + - [Systemd commands](#systemd-commands) + - [Control port](#control-port) + - [Daily statistics](#daily-statistics) +- [Reproduce builds](#reproduce-builds) +- [Updating your SMP server](#updating-your-smp-server) +- [Configuring the app to use the server](#configuring-the-app-to-use-the-server) + +## Overview + +SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp11, smp12 and smp14.simplex.im), but you can easily change app configuration to use other servers. + +SimpleX clients only determine which server is used to receive the messages, separately for each contact (or group connection with a group member), and these servers are only temporary, as the delivery address can change. + +To create SMP server, you'll need: + +1. VPS or any other server. +2. Your own domain, pointed at the server (`smp.example.com`) +3. A basic Linux knowledge. + +_Please note_: when you change the servers in the app configuration, it only affects which servers will be used for the new contacts, the existing contacts will not automatically move to the new servers, but you can move them manually using ["Change receiving address"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) button in contact/member information pages – it will be automated in the future. ## Quick start -To create SMP server, you'll need: +To create SMP server as a systemd service, you'll need: - VPS or any other server. - Your server domain, with A and AAAA records specifying server IPv4 and IPv6 addresses (`smp1.example.com`) @@ -228,34 +246,43 @@ To create SMP server, you'll need: echo "$smp,$tor" ``` -## Detailed guide +## Installation options -### Overview +You can install SMP server in one of the following ways: -SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp11, smp12 and smp14.simplex.im), but you can easily change app configuration to use other servers. +- [systemd service](#systemd-service) + - using [installation script](#installation-script) - **recommended** + - or [manually](#manual-deployment) +- [Docker container](#docker-container) from DockerHub +- [Linode marketplace](#linode-marketplace) -SimpleX clients only determine which server is used to receive the messages, separately for each contact (or group connection with a group member), and these servers are only temporary, as the delivery address can change. +### systemd service -To create SMP server, you'll need: +#### Installation script -1. VPS or any other server. -2. Your own domain, pointed at the server (`smp.example.com`) -3. A basic Linux knowledge. +This installation script will automatically install binaries, systemd services and additional scripts that will manage backups, updates and uninstallation. This is the recommended option due to its flexibility, easy updating, and being battle tested on our servers. -_Please note_: when you change the servers in the app configuration, it only affects which servers will be used for the new contacts, the existing contacts will not automatically move to the new servers, but you can move them manually using ["Change receiving address"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) button in contact/member information pages – it will be automated in the future. +**Please note** that currently only Ubuntu distribution is supported. -### Installation +Run the following script on the server: -1. First, install `smp-server`: +```sh +curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\ +if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then + chmod +x ./simplex-server-install.sh + ./simplex-server-install.sh + rm ./simplex-server-install.sh +else + echo "SHA-256 checksum is incorrect!" + rm ./simplex-server-install.sh +fi +``` - - Manual deployment (see below) +Type `1` and hit enter to install `smp-server`. - - Semi-automatic deployment: - - [Installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) +#### Manual deployment -Manual installation requires some preliminary actions: +Manual installation is the most advanced deployment that provides the most flexibility. Generally recommended only for advanced users. 1. Install binary: @@ -317,7 +344,202 @@ Manual installation requires some preliminary actions: And execute `sudo systemctl daemon-reload`. -### Configuration +### Docker container + +You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. + +This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. + +This will download images from [Docker Hub](https://hub.docker.com/r/simplexchat). + +#### Docker: Automatic setup + +This configuration provides quick and easy way to setup your SMP server: Caddy will automatically manage Let's Encrypt certificates and redirect HTTP to HTTPS, while smp-server will serve both [server information page](#server-information-page) and SMP Protocol by 443 port. 5223 port is used as fallback. + +**Please note** that you _must_ have `80` and `443` ports unallocated by other servers. + +1. Create `smp-server` directory and switch to it: + + ```sh + mkdir smp-server && cd smp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-smp-complete.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-complete.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - smp-server + + services: + oneshot: + image: ubuntu:latest + environment: + CADDYCONF: | + ${CADDY_OPTS:-} + + http://{$$ADDR} { + redir https://{$$ADDR}{uri} permanent + } + + {$$ADDR}:8443 { + tls { + key_type rsa4096 + } + } + command: sh -c 'if [ ! -f /etc/caddy/Caddyfile ]; then printf "$${CADDYCONF}" > /etc/caddy/Caddyfile; fi' + volumes: + - ./caddy_conf:/etc/caddy + + caddy: + image: caddy:latest + depends_on: + oneshot: + condition: service_completed_successfully + cap_add: + - NET_ADMIN + environment: + ADDR: ${ADDR?"Please specify the domain."} + volumes: + - ./caddy_conf:/etc/caddy + - caddy_data:/data + - caddy_config:/config + ports: + - 80:80 + restart: unless-stopped + healthcheck: + test: "test -d /data/caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR} || exit 1" + interval: 1s + retries: 60 + + smp-server: + image: ${SIMPLEX_IMAGE:-simplexchat/smp-server:latest} + depends_on: + caddy: + condition: service_healthy + environment: + ADDR: ${ADDR?"Please specify the domain."} + PASS: ${PASS:-} + volumes: + - ./smp_configs:/etc/opt/simplex + - ./smp_state:/var/opt/simplex + - type: volume + source: caddy_data + target: /certificates + volume: + subpath: "caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR}" + ports: + - 443:443 + - 5223:5223 + restart: unless-stopped + + volumes: + caddy_data: + caddy_config: + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-smp-complete.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-complete.env). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + + # Optional + #PASS='123123' + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +#### Docker: Manual setup + +If you know what you are doing, this configuration provides bare SMP server setup without automatically managed Let's Encrypt certificates by Caddy to serve [server information page](#server-information-page) with 5223 port set as primary. + +This configuration allows you to retain the ability to manage 80 and 443 ports yourself. As a downside, SMP server **can not* be served to 443 port. + +1. Create `smp-server` directory and switch to it: + + ```sh + mkdir smp-server && cd smp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-smp-manual.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-manual.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - smp-server + + services: + smp-server: + image: ${SIMPLEX_IMAGE:-simplexchat/smp-server:latest} + environment: + WEB_MANUAL: ${WEB_MANUAL:-1} + ADDR: ${ADDR?"Please specify the domain."} + PASS: ${PASS:-} + volumes: + - ./smp_configs:/etc/opt/simplex + - ./smp_state:/var/opt/simplex + ports: + - 5223:5223 + restart: unless-stopped + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-smp-manual.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-manual.env). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + + # Optional + #PASS='123123' + WEB_MANUAL=1 + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +### Linode marketplace + +You can deploy smp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + +## Verifying server binaries + +Starting from v6.3 server builds are [reproducible](#reproduce-builds). + +That also allows us to sign server releases, confirming the integrity of GitHub builds. + +To verify server binaries after you downloaded them: + +1. Download `_sha256sums` (hashes of all server binaries) and `_sha256sums.asc` (signature). + +2. Download our key FB44AF81A45BDE327319797C85107E357D4A17FC from [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) + +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 _sha256sums.asc _sha256sums`. It should print: + +> Good signature from "SimpleX Chat " + +5. Compute the hashes of the binaries you plan to use with `shu256sum ` or with `openssl sha256 ` and compare them with the hashes in the file `_sha256sums` - they must be the same. + +That is it - you now verified authenticity of our GitHub server binaries. + +## Configuration To see which options are available, execute `smp-server` without flags: @@ -337,7 +559,7 @@ You can get further help by executing `sudo su smp -c "smp-server -h"` After that, we need to configure `smp-server`: -#### Interactively +### Interactively Execute the following command: @@ -367,7 +589,7 @@ These statistics include daily counts of created, secured and deleted queues, se Enter your domain or ip address that your smp-server is running on - it will be included in server certificates and also printed as part of server address. -#### Via command line options +### Via command line options Execute the following command: @@ -431,7 +653,7 @@ Server address: smp://d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss=:V8ONoJ6ICwnrZ The server address above should be used in your client configuration, and if you added server password it should only be shared with the other people who you want to allow using your server to receive the messages (all your contacts will be able to send messages - it does not require a password). If you passed IP address or hostnames during the initialisation, they will be printed as part of server address, otherwise replace `` with the actual server hostnames. -### Further configuration +## Further configuration All generated configuration, along with a description for each parameter, is available inside configuration file in `/etc/opt/simplex/smp-server.ini` for further customization. Depending on the smp-server version, the configuration file looks something like this: @@ -560,9 +782,9 @@ cert: /etc/opt/simplex/web.crt key: /etc/opt/simplex/web.key ``` -### Server security +## Server security -#### Initialization +### Initialization Although it's convenient to initialize smp-server configuration directly on the server, operators **ARE ADVISED** to initialize smp-server fully offline to protect your SMP server CA private key. @@ -582,7 +804,7 @@ Follow the steps to quickly initialize the server offline: rsync -hzasP $HOME/simplex/smp/config/ @:/etc/opt/simplex/ ``` -#### Private keys +### Private keys Connection to the smp server occurs via a TLS connection. During the TLS handshake, the client verifies smp-server CA and server certificates by comparing its fingerprint with the one included in server address. If server TLS credential is compromised, this key can be used to sign a new one, keeping the same server identity and established connections. In order to protect your smp-server from bad actors, operators **ARE ADVISED** to move CA private key to a safe place. That could be: @@ -607,7 +829,7 @@ Follow the steps to secure your CA keys: rm /etc/opt/simplex/ca.key ``` -#### Online certificate rotation +### Online certificate rotation Operators of smp servers **ARE ADVISED** to rotate online certificate regularly (e.g., every 3 months). In order to do this, follow the steps: @@ -683,9 +905,9 @@ Operators of smp servers **ARE ADVISED** to rotate online certificate regularly 10. Done! -### Tor: installation and configuration +## Tor: installation and configuration -#### Installation for onion address +### Installation for onion address SMP-server can also be deployed to be available via [Tor](https://www.torproject.org) network. Run the following commands as `root` user. @@ -766,7 +988,7 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject cat /var/lib/tor/simplex-smp/hostname ``` -#### SOCKS port for SMP PROXY +### SOCKS port for SMP PROXY SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp servers available exclusively through [Tor](https://www.torproject.org) network to be accessible to the clients that do not use Tor. Run the following commands as `root` user. @@ -813,7 +1035,7 @@ SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp ... ``` -### Server information page +## Server information page SMP server **SHOULD** be configured to serve Web page with server information that can include admin info, server info, provider info, etc. It will also serve connection links, generated using the mobile/desktop apps. Run the following commands as `root` user. @@ -995,13 +1217,13 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. 10. Access the webpage you've deployed from your browser (`https://smp.example.org`). You should see the smp-server information that you've provided in your ini file. -### Documentation +## Documentation All necessary files for `smp-server` are located in `/etc/opt/simplex/` folder. Stored messages, connections, statistics and server log are located in `/var/opt/simplex/` folder. -#### SMP server address +### SMP server address SMP server address has the following format: @@ -1021,7 +1243,7 @@ smp://[:]@[,] Your configured hostname(s) of `smp-server`. You can check your configured hosts in `/etc/opt/simplex/smp-server.ini`, under `[TRANSPORT]` section in `host:` field. -#### Systemd commands +### Systemd commands To start `smp-server` on host boot, run: @@ -1080,7 +1302,7 @@ Nov 23 19:23:21 5588ab759e80 smp-server[30878]: not expiring inactive clients Nov 23 19:23:21 5588ab759e80 smp-server[30878]: creating new queues requires password ``` -#### Control port +### Control port Enabling control port in the configuration allows administrator to see information about the smp-server in real-time. Additionally, it allows to delete queues for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. @@ -1154,7 +1376,7 @@ Here's the full list of commands, their descriptions and who can access them. | `help` | Help menu. | - | | `quit` | Exit the control port. | - | -#### Daily statistics +### Daily statistics You can enable `smp-server` statistics for `Grafana` dashboard by setting value `on` in `/etc/opt/simplex/smp-server.ini`, under `[STORE_LOG]` section in `log_stats:` field. @@ -1280,7 +1502,7 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) -### Updating your SMP server +## Updating your SMP server To update your smp-server to latest version, choose your installation method and follow the steps: @@ -1366,7 +1588,57 @@ To update your smp-server to latest version, choose your installation method and docker image prune ``` -### Configuring the app to use the server +## Reproduce builds + +You can locally reproduce server binaries, following these instructions. + +If you are a security expert or researcher, you can help SimpleX network and users community by signing the release checksums – we will [publish your signature](https://github.com/simplex-chat/simplexmq/releases/tag/v6.3.1). Please reach out to us! + +To reproduce the build you must have: + +- Linux machine +- `x86-64` architecture +- Installed `docker`, `curl` and `git` + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/master/scripts/simplexmq-reproduce-builds.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x simplexmq-reproduce-builds.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./simplexmq-reproduce-builds.sh 'v6.3.1' + ``` + + The script executes these steps (please review the script to confirm): + + 1) builds all server binaries for the release in docker container. + 2) downloads binaries from the same GitHub release and compares them with the built binaries. + 3) if they all match, generates _sha256sums file with their checksums. + + This will take a while. + +4. After compilation, you should see the folder named as the tag (e.g., `v6.3.1`) with two subfolders: + + ```sh + ls v6.3.1 + ``` + + ```sh + from-source prebuilt _sha256sums + ``` + + The file _sha256sums contains the hashes of all builds - you can compare it with the same file in GitHub release. + +## Configuring the app to use the server To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 88428a0dc3..ba4770644e 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -4,6 +4,23 @@ revision: 31.07.2023 --- # Hosting your own XFTP Server +## Table of Contents + +- [Overview](#overview) +- [Installation options](#installation-options) + - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) + - [docker container](#docker-сontainer) + - [Linode marketplace](#linode-marketplace) +- [Tor installation](#tor-installation) +- [Configuration](#configuration) +- [Documentation](#documentation) + - [XFTP server address](#xftp-server-address) + - [Systemd commands](#systemd-commands) + - [Control port](#control-port) + - [Daily statistics](#daily-statistics) +- [Updating your XFTP server](#updating-your-xftp-server) +- [Configuring the app to use the server](#configuring-the-app-to-use-the-server) + ## Overview XFTP is a new file transfer protocol focussed on meta-data protection - it is based on the same principles as SimpleX Messaging Protocol used in SimpleX Chat messenger: @@ -15,20 +32,45 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba - no identifiers or ciphertext in common between sent and received relay traffic, same as for messages delivered by SMP relays. - protection of sender IP address from the recipients. -## Installation +## Installation options -0. First, install `xftp-server`: +You can install XFTP server in one of the following ways: - - Manual deployment (see below) +- [systemd service](#systemd-service) + - using [installation script](#installation-script) - **recommended** + - or [manually](#manual-deployment) +- [Docker container](#docker-container) from DockerHub +- [Linode marketplace](#linode-marketplace) - - Semi-automatic deployment: - - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) +### systemd service -Manual installation requires some preliminary actions: +#### Installation script -0. Install binary: +This installation script will automatically install binaries, systemd services and additional scripts that will manage backups, updates and uninstallation. This is the recommended option due to its flexibility, easy updating, and being battle tested on our servers. + +**Please note** that currently only Ubuntu distribution is supported. + +Run the following script on the server: + +```sh +curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\ +if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then + chmod +x ./simplex-server-install.sh + ./simplex-server-install.sh + rm ./simplex-server-install.sh +else + echo "SHA-256 checksum is incorrect!" + rm ./simplex-server-install.sh +fi +``` + +Type `2` and hit enter to install `xftp-server`. + +#### Manual deployment + +Manual installation is the most advanced deployment that provides the most flexibility. Generally recommended only for advanced users. + +1. Install binary: - Using offical binaries: @@ -41,20 +83,20 @@ Manual installation requires some preliminary actions: Please refer to [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution) -1. Create user and group for `xftp-server`: +2. Create user and group for `xftp-server`: ```sh sudo useradd -m xftp ``` -2. Create necessary directories and assign permissions: +3. Create necessary directories and assign permissions: ```sh sudo mkdir -p /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp sudo chown xftp:xftp /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp ``` -3. Allow xftp-server port in firewall: +4. Allow xftp-server port in firewall: ```sh # For Ubuntu @@ -64,7 +106,7 @@ Manual installation requires some preliminary actions: sudo firewall-cmd --reload ``` -4. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content: +5. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content: ```sh [Unit] @@ -87,6 +129,68 @@ Manual installation requires some preliminary actions: And execute `sudo systemctl daemon-reload`. +### Docker сontainer + +You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. + +This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. + +This will download images from [Docker Hub](https://hub.docker.com/r/simplexchat). + +1. Create `xftp-server` directory and switch to it: + + ```sh + mkdir xftp-server && cd xftp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-xftp.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-xftp.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - xftp-server + + services: + xftp-server: + image: ${SIMPLEX_XFTP_IMAGE:-simplexchat/xftp-server:latest} + environment: + ADDR: ${ADDR?"Please specify the domain."} + QUOTA: ${QUOTA?"Please specify disk quota."} + PASS: ${PASS:-} + volumes: + - ./xftp_configs:/etc/opt/simplex-xftp + - ./xftp_state:/var/opt/simplex-xftp + - ./xftp_files:/srv/xftp + ports: + - 443:443 + restart: unless-stopped + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-xftp.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-xftp.yml). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + QUOTA=120gb + + # Optional + #PASS='123123' + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +### Linode marketplace + +You can deploy xftp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + ## Tor installation xftp-server can also be deployed to serve from [tor](https://www.torproject.org) network. Run the following commands as `root` user. @@ -359,11 +463,11 @@ Feb 27 19:21:11 localhost xftp-server[2350]: Store log: /var/opt/simplex-xftp/fi Feb 27 19:21:11 localhost xftp-server[2350]: Uploading new files allowed. Feb 27 19:21:11 localhost xftp-server[2350]: Listening on port 443... Feb 27 19:21:11 localhost xftp-server[2350]: [INFO 2023-02-27 19:21:11 +0000 src/Simplex/FileTransfer/Server/Env.hs:85] Total / available storage: 64424509440 / 64424509440 -```` +``` ### Control port -Enabling control port in the configuration allows administrator to see information about the smp-server in real-time. Additionally, it allows to delete file chunks for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. +Enabling control port in the configuration allows administrator to see information about the xftp-server in real-time. Additionally, it allows to delete file chunks for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. 1. Generate two passwords for each user: @@ -487,7 +591,7 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) -# Updating your XFTP server +## Updating your XFTP server To update your XFTP server to latest version, choose your installation method and follow the steps: @@ -545,6 +649,6 @@ To update your XFTP server to latest version, choose your installation method an docker image prune ``` -### Configuring the app to use the server +## Configuring the app to use the server Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server). diff --git a/docs/dependencies/HASKELL.md b/docs/dependencies/HASKELL.md new file mode 100644 index 0000000000..e6b180e33b --- /dev/null +++ b/docs/dependencies/HASKELL.md @@ -0,0 +1,177 @@ +# Dependency License Report + +Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3`. + +## Direct dependencies of `simplex-chat:exe:simplex-chat` + +| Name | Version | [SPDX](https://spdx.org/licenses/) License Id | Description | Also depended upon by | +| --- | --- | --- | --- | --- | +| **`base`** | [`4.18.1.0`](http://hackage.haskell.org/package/base-4.18.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/base-4.18.1.0/src/LICENSE) | Basic libraries | *(core library)* | +| `base64-bytestring` | [`1.2.1.0`](http://hackage.haskell.org/package/base64-bytestring-1.2.1.0) | [`BSD-3-Clause`](./licences/haskell/base64-bytestring-1.2.1.0/LICENSE) | Fast base64 encoding and decoding for ByteStrings | `simplexmq`, `websockets` | +| **`bytestring`** | [`0.11.5.2`](http://hackage.haskell.org/package/bytestring-0.11.5.2) | [`BSD-3-Clause`](http://hackage.haskell.org/package/bytestring-0.11.5.2/src/LICENSE) | Fast, compact, strict and lazy byte strings with a list interface | `SHA`, `aeson`, `appar`, `asn1-encoding`, `asn1-parse`, `asn1-types`, `attoparsec`, `base64-bytestring`, `binary`, `bitvec`, `blaze-builder`, `blaze-textual`, `bytestring-builder`, `case-insensitive`, `cereal`, `conduit`, `conduit-extra`, `cryptohash-md5`, `cryptohash-sha1`, `crypton`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `digest`, `direct-sqlcipher`, `email-validate`, `entropy`, `fast-logger`, `file-embed`, `filepath`, `hashable`, `http-types`, `http2`, `integer-conversion`, `iproute`, `libyaml`, `memory`, `monad-logger`, `mono-traversable`, `network`, `network-byte-order`, `network-transport`, `network-udp`, `parsec`, `pem`, `random`, `scientific`, `simplexmq`, `socks`, `sqlcipher-simple`, `streaming-commons`, `strict`, `terminal`, `text`, `text-short`, `tls`, `typed-process`, `unix`, `unix-time`, `unliftio`, `uuid`, `uuid-types`, `vector-algorithms`, `websockets`, `yaml`, `zip`, `zlib`, `zstd` | +| `composition` | [`1.0.2.2`](http://hackage.haskell.org/package/composition-1.0.2.2) | [`BSD-3-Clause`](./licences/haskell/composition-1.0.2.2/LICENSE) | Combinators for unorthodox function composition | `simplexmq` | +| `constraints` | [`0.14`](http://hackage.haskell.org/package/constraints-0.14) | [`BSD-2-Clause`](./licences/haskell/constraints-0.14/LICENSE) | Constraint manipulation | `simplexmq` | +| **`containers`** | [`0.6.7`](http://hackage.haskell.org/package/containers-0.6.7) | [`BSD-3-Clause`](http://hackage.haskell.org/package/containers-0.6.7/src/LICENSE) | Assorted concrete container types | `QuickCheck`, `aeson`, `attoparsec`, `bifunctors`, `binary`, `cereal`, `comonad`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `data-default-instances-containers`, `hashable`, `http2`, `indexed-traversable`, `iproute`, `mono-traversable`, `network-udp`, `resourcet`, `scientific`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `th-abstraction`, `websockets`, `witherable`, `yaml`, `zip` | +| `crypton` | [`0.34`](http://hackage.haskell.org/package/crypton-0.34) | [`BSD-3-Clause`](./licences/haskell/crypton-0.34/LICENSE) | Cryptography Primitives sink | `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `uuid` | [`1.3.15`](http://hackage.haskell.org/package/uuid-1.3.15) | [`BSD-3-Clause`](./licences/haskell/uuid-1.3.15/LICENSE) | For creating, comparing, parsing and printing Universally Unique Identifiers | | +| **`directory`** | [`1.3.8.1`](http://hackage.haskell.org/package/directory-1.3.8.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/directory-1.3.8.1/src/LICENSE) | Platform-agnostic library for filesystem operations | `conduit`, `conduit-extra`, `crypton-x509-store`, `easy-file`, `fast-logger`, `file-embed`, `network`, `process`, `simplexmq`, `streaming-commons`, `temporary`, `unliftio`, `yaml`, `zip` | +| `direct-sqlcipher` | [`2.3.28`](http://hackage.haskell.org/package/direct-sqlcipher-2.3.28) | *MISSING* | *MISSING* | `simplexmq`, `sqlcipher-simple` | +| `data-default` | [`0.7.1.1`](http://hackage.haskell.org/package/data-default-0.7.1.1) | [`BSD-3-Clause`](./licences/haskell/data-default-0.7.1.1/LICENSE) | A class for types with a default value | `simplexmq` | +| **`exceptions`** | [`0.10.7`](http://hackage.haskell.org/package/exceptions-0.10.7) | [`BSD-3-Clause`](http://hackage.haskell.org/package/exceptions-0.10.7/src/LICENSE) | Extensible optionally-pure exceptions | `aeson`, `conduit`, `filepath`, `monad-logger`, `resourcet`, `safe-exceptions`, `sqlcipher-simple`, `temporary`, `terminal`, `zip` | +| **`filepath`** | [`1.4.100.4`](http://hackage.haskell.org/package/filepath-1.4.100.4) | [`BSD-3-Clause`](http://hackage.haskell.org/package/filepath-1.4.100.4/src/LICENSE) | Library for manipulating FilePaths in a cross platform way. | `conduit`, `conduit-extra`, `crypton-x509-store`, `directory`, `easy-file`, `fast-logger`, `file-embed`, `hashable`, `process`, `simplexmq`, `temporary`, `unix`, `unliftio`, `yaml`, `zip` | +| `file-embed` | [`0.0.15.0`](http://hackage.haskell.org/package/file-embed-0.0.15.0) | [`BSD-2-Clause`](./licences/haskell/file-embed-0.0.15.0/LICENSE) | Use Template Haskell to embed file contents directly. | | +| `http-types` | [`0.12.4`](http://hackage.haskell.org/package/http-types-0.12.4) | [`BSD-3-Clause`](./licences/haskell/http-types-0.12.4/LICENSE) | Generic HTTP types for Haskell (for both client and server code). | `http2`, `simplexmq` | +| `http2` | [`5.0.0`](http://hackage.haskell.org/package/http2-5.0.0) | [`BSD-3-Clause`](./licences/haskell/http2-5.0.0/LICENSE) | HTTP/2 library | `simplexmq` | +| `email-validate` | [`2.3.2.19`](http://hackage.haskell.org/package/email-validate-2.3.2.19) | [`BSD-3-Clause`](./licences/haskell/email-validate-2.3.2.19/LICENSE) | Email address validation | | +| `memory` | [`0.18.0`](http://hackage.haskell.org/package/memory-0.18.0) | [`BSD-3-Clause`](./licences/haskell/memory-0.18.0/LICENSE) | memory and related abstraction stuff | `asn1-types`, `crypton`, `crypton-x509`, `crypton-x509-validation`, `cryptostore`, `pem`, `simplexmq`, `tls` | +| **`mtl`** | [`2.3.1`](http://hackage.haskell.org/package/mtl-2.3.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/mtl-2.3.1/src/LICENSE) | Monad classes for transformers, using functional dependencies | `conduit`, `constraints`, `crypton-x509-store`, `crypton-x509-validation`, `exceptions`, `monad-logger`, `parsec`, `random`, `resourcet`, `simple-logger`, `simplexmq`, `tls`, `yaml`, `zip` | +| `unliftio` | [`0.2.25.0`](http://hackage.haskell.org/package/unliftio-0.2.25.0) | [`MIT`](./licences/haskell/unliftio-0.2.25.0/LICENSE) | The MonadUnliftIO typeclass for unlifting monads to IO (batteries included) | `http2`, `simplexmq`, `time-manager` | +| `unliftio-core` | [`0.2.1.0`](http://hackage.haskell.org/package/unliftio-core-0.2.1.0) | [`MIT`](./licences/haskell/unliftio-core-0.2.1.0/LICENSE) | The MonadUnliftIO typeclass for unlifting monads to IO | `conduit`, `conduit-extra`, `monad-logger`, `resourcet`, `simplexmq`, `typed-process`, `unliftio` | +| `ansi-terminal` | [`1.0`](http://hackage.haskell.org/package/ansi-terminal-1.0) | [`BSD-3-Clause`](./licences/haskell/ansi-terminal-1.0/LICENSE) | Simple ANSI terminal support | `prettyprinter-ansi-terminal` | +| `network` | [`3.1.4.0`](http://hackage.haskell.org/package/network-3.1.4.0) | [`BSD-3-Clause`](./licences/haskell/network-3.1.4.0/LICENSE) | Low-level networking interface | `conduit-extra`, `http2`, `iproute`, `network-udp`, `simplexmq`, `socks`, `streaming-commons`, `tls`, `websockets` | +| `network-transport` | [`0.5.6`](http://hackage.haskell.org/package/network-transport-0.5.6) | [`BSD-3-Clause`](./licences/haskell/network-transport-0.5.6/LICENSE) | Network abstraction layer | `simplexmq` | +| **`process`** | [`1.6.17.0`](http://hackage.haskell.org/package/process-1.6.17.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/process-1.6.17.0/src/LICENSE) | Process libraries | `conduit-extra`, `optparse-applicative`, `simplexmq`, `streaming-commons`, `typed-process`, `unliftio` | +| `optparse-applicative` | [`0.18.1.0`](http://hackage.haskell.org/package/optparse-applicative-0.18.1.0) | [`BSD-3-Clause`](./licences/haskell/optparse-applicative-0.18.1.0/LICENSE) | Utilities and combinators for parsing command line options | `simplexmq` | +| `record-hasfield` | [`1.0`](http://hackage.haskell.org/package/record-hasfield-1.0) | [`BSD-3-Clause`](./licences/haskell/record-hasfield-1.0/LICENSE) | A version of GHC.Records as available in future GHCs. | | +| `random` | [`1.2.1.1`](http://hackage.haskell.org/package/random-1.2.1.1) | [`BSD-3-Clause`](./licences/haskell/random-1.2.1.1/LICENSE) | Pseudo-random number generation | `QuickCheck`, `simplexmq`, `streaming-commons`, `temporary`, `uuid`, `uuid-types`, `websockets` | +| `socks` | [`0.6.1`](http://hackage.haskell.org/package/socks-0.6.1) | [`BSD-3-Clause`](./licences/haskell/socks-0.6.1/LICENSE) | Socks proxy (ver 5) | `simplexmq` | +| `scientific` | [`0.3.7.0`](http://hackage.haskell.org/package/scientific-0.3.7.0) | [`BSD-3-Clause`](./licences/haskell/scientific-0.3.7.0/LICENSE) | Numbers represented using scientific notation | `aeson`, `attoparsec`, `yaml` | +| `simple-logger` | [`0.1.1`](http://hackage.haskell.org/package/simple-logger-0.1.1) | [`MIT`](./licences/haskell/simple-logger-0.1.1/LICENSE) | A very simple but efficient logging framework | `simplexmq` | +| `simplexmq` | [`6.4.0.1`](http://hackage.haskell.org/package/simplexmq-6.4.0.1) | *MISSING* | *MISSING* | | +| `aeson` | [`2.2.1.0`](http://hackage.haskell.org/package/aeson-2.2.1.0) | [`BSD-3-Clause`](./licences/haskell/aeson-2.2.1.0/LICENSE) | Fast JSON parsing and encoding | `simplexmq`, `yaml` | +| `sqlcipher-simple` | [`0.4.18.1`](http://hackage.haskell.org/package/sqlcipher-simple-0.4.18.1) | *MISSING* | *MISSING* | `simplexmq` | +| **`stm`** | [`2.5.1.0`](http://hackage.haskell.org/package/stm-2.5.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/stm-2.5.1.0/src/LICENSE) | Software Transactional Memory | `StateVar`, `async`, `conduit-extra`, `exceptions`, `fast-logger`, `http2`, `monad-control`, `monad-logger`, `simplexmq`, `stm-chans`, `streaming-commons`, `terminal`, `transformers-base`, `typed-process`, `unliftio` | +| `async` | [`2.2.5`](http://hackage.haskell.org/package/async-2.2.5) | [`BSD-3-Clause`](./licences/haskell/async-2.2.5/LICENSE) | Run IO operations asynchronously and wait for their results | `conduit-extra`, `http2`, `simplexmq`, `streaming-commons`, `terminal`, `tls`, `typed-process`, `unliftio`, `websockets` | +| **`template-haskell`** | [`2.20.0.0`](http://hackage.haskell.org/package/template-haskell-2.20.0.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/template-haskell-2.20.0.0/src/LICENSE) | Support library for Template Haskell | `OneTuple`, `QuickCheck`, `aeson`, `bifunctors`, `bytestring`, `containers`, `email-validate`, `exceptions`, `file-embed`, `filepath`, `monad-logger`, `network-uri`, `primitive`, `scientific`, `semigroupoids`, `sqlcipher-simple`, `tagged`, `text`, `text-short`, `th-abstraction`, `th-compat`, `unordered-containers`, `uuid-types`, `yaml` | +| **`text`** | [`2.0.2`](http://hackage.haskell.org/package/text-2.0.2) | [`BSD-2-Clause`](http://hackage.haskell.org/package/text-2.0.2/src/LICENSE) | An efficient packed Unicode text type. | `aeson`, `attoparsec`, `blaze-builder`, `blaze-textual`, `case-insensitive`, `conduit`, `conduit-extra`, `direct-sqlcipher`, `fast-logger`, `hashable`, `http-types`, `ini`, `integer-conversion`, `monad-logger`, `mono-traversable`, `optparse-applicative`, `parsec`, `prettyprinter`, `prettyprinter-ansi-terminal`, `scientific`, `simple-logger`, `simplexmq`, `sqlcipher-simple`, `streaming-commons`, `strict`, `terminal`, `text-iso8601`, `text-short`, `uuid`, `uuid-types`, `websockets`, `yaml`, `zip` | +| **`time`** | [`1.12.2`](http://hackage.haskell.org/package/time-1.12.2) | [`BSD-2-Clause`](http://hackage.haskell.org/package/time-1.12.2/src/LICENSE) | A time library | `aeson`, `directory`, `easy-file`, `iso8601-time`, `simplexmq`, `sqlcipher-simple`, `text-iso8601`, `time-compat`, `unix`, `unliftio`, `uuid`, `zip` | +| `tls` | [`1.9.0`](http://hackage.haskell.org/package/tls-1.9.0) | [`BSD-3-Clause`](./licences/haskell/tls-1.9.0/LICENSE) | TLS/SSL protocol native implementation (Server and Client) | `simplexmq` | +| `terminal` | [`0.2.0.0`](http://hackage.haskell.org/package/terminal-0.2.0.0) | [`BSD-3-Clause`](./licences/haskell/terminal-0.2.0.0/LICENSE) | Portable terminal interaction library | | +| `attoparsec` | [`0.14.4`](http://hackage.haskell.org/package/attoparsec-0.14.4) | [`BSD-3-Clause`](./licences/haskell/attoparsec-0.14.4/LICENSE) | Fast combinator parsing for bytestrings and text | `conduit-extra`, `email-validate`, `ini`, `simplexmq`, `sqlcipher-simple`, `websockets`, `yaml` | +| `websockets` | [`0.12.7.3`](http://hackage.haskell.org/package/websockets-0.12.7.3) | [`BSD-3-Clause`](./licences/haskell/websockets-0.12.7.3/LICENCE) | A sensible and clean way to write WebSocket-capable servers in Haskell. | `simplexmq` | +| `zip` | [`2.0.0`](http://hackage.haskell.org/package/zip-2.0.0) | [`BSD-3-Clause`](./licences/haskell/zip-2.0.0/LICENSE.md) | Operations on zip archives | | + +## Indirect transitive dependencies + +| Name | Version | [SPDX](https://spdx.org/licenses/) License Id | Description | Depended upon by | +| --- | --- | --- | --- | --- | +| `OneTuple` | [`0.4.1.1`](http://hackage.haskell.org/package/OneTuple-0.4.1.1) | [`BSD-3-Clause`](./licences/haskell/OneTuple-0.4.1.1/LICENSE) | Singleton Tuple | `aeson`, `indexed-traversable-instances` | +| `Only` | [`0.1`](http://hackage.haskell.org/package/Only-0.1) | [`BSD-3-Clause`](./licences/haskell/Only-0.1/LICENSE) | The 1-tuple type or single-value "collection" | `sqlcipher-simple` | +| `QuickCheck` | [`2.14.3`](http://hackage.haskell.org/package/QuickCheck-2.14.3) | [`BSD-3-Clause`](./licences/haskell/QuickCheck-2.14.3/LICENSE) | Automatic testing of Haskell programs | `aeson` | +| `SHA` | [`1.6.4.4`](http://hackage.haskell.org/package/SHA-1.6.4.4) | [`BSD-3-Clause`](./licences/haskell/SHA-1.6.4.4/LICENSE) | Implementations of the SHA suite of message digest functions | `websockets` | +| `StateVar` | [`1.2.2`](http://hackage.haskell.org/package/StateVar-1.2.2) | [`BSD-3-Clause`](./licences/haskell/StateVar-1.2.2/LICENSE) | State variables | `contravariant` | +| **`array`** | [`0.5.5.0`](http://hackage.haskell.org/package/array-0.5.5.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/array-0.5.5.0/src/LICENSE) | Mutable and immutable arrays | `SHA`, `attoparsec`, `binary`, `cereal`, `containers`, `deepseq`, `fast-logger`, `http-types`, `http2`, `indexed-traversable`, `integer-logarithms`, `stm`, `streaming-commons`, `text` | +| `bifunctors` | [`5.6.1`](http://hackage.haskell.org/package/bifunctors-5.6.1) | [`BSD-3-Clause`](./licences/haskell/bifunctors-5.6.1/LICENSE) | Bifunctors | `semigroupoids` | +| **`binary`** | [`0.8.9.1`](http://hackage.haskell.org/package/binary-0.8.9.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/binary-0.8.9.1/src/LICENSE) | Binary serialisation for Haskell values using lazy ByteStrings | `SHA`, `constraints`, `network-transport`, `scientific`, `strict`, `text`, `text-short`, `these`, `unix-time`, `uuid`, `uuid-types`, `websockets` | +| `blaze-builder` | [`0.4.2.3`](http://hackage.haskell.org/package/blaze-builder-0.4.2.3) | [`BSD-3-Clause`](./licences/haskell/blaze-builder-0.4.2.3/LICENSE) | Efficient buffered output. | `blaze-textual`, `sqlcipher-simple` | +| `blaze-textual` | [`0.2.3.1`](http://hackage.haskell.org/package/blaze-textual-0.2.3.1) | [`BSD-3-Clause`](./licences/haskell/blaze-textual-0.2.3.1/LICENSE) | Fast rendering of common datatypes | `sqlcipher-simple` | +| `boring` | [`0.2.1`](http://hackage.haskell.org/package/boring-0.2.1) | [`BSD-3-Clause`](./licences/haskell/boring-0.2.1/LICENSE) | Boring and Absurd types | `constraints` | +| `base-orphans` | [`0.9.1`](http://hackage.haskell.org/package/base-orphans-0.9.1) | [`MIT`](./licences/haskell/base-orphans-0.9.1/LICENSE) | Backwards-compatible orphan instances for base | `distributive`, `semigroupoids`, `time-compat`, `transformers-base`, `witherable` | +| `basement` | [`0.0.16`](http://hackage.haskell.org/package/basement-0.0.16) | [`BSD-3-Clause`](./licences/haskell/basement-0.0.16/LICENSE) | Foundation scrap box of array & string | `crypton`, `cryptostore`, `memory`, `pem`, `socks` | +| `bitvec` | [`1.1.5.0`](http://hackage.haskell.org/package/bitvec-1.1.5.0) | [`BSD-3-Clause`](./licences/haskell/bitvec-1.1.5.0/LICENSE) | Space-efficient bit vectors | `vector-algorithms` | +| `byteorder` | [`1.0.4`](http://hackage.haskell.org/package/byteorder-1.0.4) | [`BSD-3-Clause`](./licences/haskell/byteorder-1.0.4/LICENSE) | Exposes the native endianness or byte ordering of the system. | `iproute` | +| `bytestring-builder` | [`0.10.8.2.0`](http://hackage.haskell.org/package/bytestring-builder-0.10.8.2.0) | [`BSD-3-Clause`](./licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE) | The new bytestring builder, packaged outside of GHC | `websockets` | +| `clock` | [`0.8.4`](http://hackage.haskell.org/package/clock-0.8.4) | [`BSD-3-Clause`](./licences/haskell/clock-0.8.4/LICENSE) | High-resolution clock functions: monotonic, realtime, cputime. | `websockets` | +| `colour` | [`2.3.6`](http://hackage.haskell.org/package/colour-2.3.6) | [`MIT`](./licences/haskell/colour-2.3.6/LICENSE) | A model for human colour/color perception | `ansi-terminal`, `ansi-terminal-types` | +| `comonad` | [`5.0.8`](http://hackage.haskell.org/package/comonad-5.0.8) | [`BSD-3-Clause`](./licences/haskell/comonad-5.0.8/LICENSE) | Comonads | `bifunctors`, `semigroupoids` | +| `conduit` | [`1.3.5`](http://hackage.haskell.org/package/conduit-1.3.5) | [`MIT`](./licences/haskell/conduit-1.3.5/LICENSE) | Streaming data processing library. | `conduit-extra`, `libyaml`, `monad-logger`, `yaml`, `zip` | +| `conduit-extra` | [`1.3.6`](http://hackage.haskell.org/package/conduit-extra-1.3.6) | [`MIT`](./licences/haskell/conduit-extra-1.3.6/LICENSE) | Batteries included conduit: adapters for common libraries. | `monad-logger`, `zip` | +| `contravariant` | [`1.5.5`](http://hackage.haskell.org/package/contravariant-1.5.5) | [`BSD-3-Clause`](./licences/haskell/contravariant-1.5.5/LICENSE) | Contravariant functors | `semigroupoids` | +| `cereal` | [`0.5.8.3`](http://hackage.haskell.org/package/cereal-0.5.8.3) | [`BSD-3-Clause`](./licences/haskell/cereal-0.5.8.3/LICENSE) | A binary serialization library | `socks`, `tls`, `zip` | +| `cryptohash-md5` | [`0.11.101.0`](http://hackage.haskell.org/package/cryptohash-md5-0.11.101.0) | [`BSD-3-Clause`](./licences/haskell/cryptohash-md5-0.11.101.0/LICENSE) | Fast, pure and practical MD5 implementation | `uuid` | +| `cryptohash-sha1` | [`0.11.101.0`](http://hackage.haskell.org/package/cryptohash-sha1-0.11.101.0) | [`BSD-3-Clause`](./licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE) | Fast, pure and practical SHA-1 implementation | `uuid` | +| `crypton-x509` | [`1.7.6`](http://hackage.haskell.org/package/crypton-x509-1.7.6) | [`BSD-3-Clause`](./licences/haskell/crypton-x509-1.7.6/LICENSE) | X509 reader and writer | `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `crypton-x509-store` | [`1.6.9`](http://hackage.haskell.org/package/crypton-x509-store-1.6.9) | [`BSD-3-Clause`](./licences/haskell/crypton-x509-store-1.6.9/LICENSE) | X.509 collection accessing and storing methods | `crypton-x509-validation`, `simplexmq`, `tls` | +| `crypton-x509-validation` | [`1.6.12`](http://hackage.haskell.org/package/crypton-x509-validation-1.6.12) | [`BSD-3-Clause`](./licences/haskell/crypton-x509-validation-1.6.12/LICENSE) | X.509 Certificate and CRL validation | `cryptostore`, `simplexmq`, `tls` | +| `cryptostore` | [`0.3.0.1`](http://hackage.haskell.org/package/cryptostore-0.3.0.1) | [`BSD-3-Clause`](./licences/haskell/cryptostore-0.3.0.1/LICENSE) | Serialization of cryptographic data types | `simplexmq` | +| `case-insensitive` | [`1.2.1.0`](http://hackage.haskell.org/package/case-insensitive-1.2.1.0) | [`BSD-3-Clause`](./licences/haskell/case-insensitive-1.2.1.0/LICENSE) | Case insensitive string comparison | `http-types`, `http2`, `simplexmq`, `websockets`, `zip` | +| `uuid-types` | [`1.0.5.1`](http://hackage.haskell.org/package/uuid-types-1.0.5.1) | [`BSD-3-Clause`](./licences/haskell/uuid-types-1.0.5.1/LICENSE) | Type definitions for Universally Unique Identifiers | `aeson`, `uuid` | +| **`deepseq`** | [`1.4.8.1`](http://hackage.haskell.org/package/deepseq-1.4.8.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/deepseq-1.4.8.1/src/LICENSE) | Deep evaluation of data structures | `Only`, `QuickCheck`, `aeson`, `attoparsec`, `bitvec`, `blaze-builder`, `bytestring`, `bytestring-builder`, `case-insensitive`, `constraints`, `containers`, `crypton`, `data-fix`, `dlist`, `filepath`, `hashable`, `hourglass`, `memory`, `network`, `network-transport`, `network-uri`, `pretty`, `primitive`, `process`, `psqueues`, `random`, `safe-exceptions`, `scientific`, `splitmix`, `strict`, `tagged`, `text`, `text-short`, `these`, `time`, `time-compat`, `unliftio`, `unordered-containers`, `uuid-types`, `vector`, `zstd` | +| `digest` | [`0.0.1.7`](http://hackage.haskell.org/package/digest-0.0.1.7) | [`BSD-2-Clause`](./licences/haskell/digest-0.0.1.7/LICENSE) | Various hashes for bytestrings; CRC32 and Adler32 for now. | `zip` | +| `dlist` | [`1.0`](http://hackage.haskell.org/package/dlist-1.0) | [`BSD-3-Clause`](./licences/haskell/dlist-1.0/license.md) | Difference lists | `aeson`, `data-default-instances-dlist`, `zip` | +| `distributive` | [`0.6.2.1`](http://hackage.haskell.org/package/distributive-0.6.2.1) | [`BSD-3-Clause`](./licences/haskell/distributive-0.6.2.1/LICENSE) | Distributive functors -- Dual to Traversable | `comonad`, `semigroupoids` | +| `data-default-class` | [`0.1.2.0`](http://hackage.haskell.org/package/data-default-class-0.1.2.0) | [`BSD-3-Clause`](./licences/haskell/data-default-class-0.1.2.0/LICENSE) | A class for types with a default value | `crypton-x509-validation`, `data-default`, `data-default-instances-containers`, `data-default-instances-dlist`, `data-default-instances-old-locale`, `tls` | +| `data-default-instances-containers` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-containers-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-containers-0.0.1/LICENSE) | Default instances for types in containers | `data-default` | +| `data-default-instances-dlist` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-dlist-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-dlist-0.0.1/LICENSE) | Default instances for types in dlist | `data-default` | +| `data-default-instances-old-locale` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-old-locale-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE) | Default instances for types in old-locale | `data-default` | +| `data-fix` | [`0.3.2`](http://hackage.haskell.org/package/data-fix-0.3.2) | [`BSD-3-Clause`](./licences/haskell/data-fix-0.3.2/LICENSE) | Fixpoint data types | `aeson` | +| `fast-logger` | [`3.2.2`](http://hackage.haskell.org/package/fast-logger-3.2.2) | [`BSD-3-Clause`](./licences/haskell/fast-logger-3.2.2/LICENSE) | A fast logging system | `monad-logger`, `simple-logger` | +| **`ghc-bignum`** | [`1.3`](http://hackage.haskell.org/package/ghc-bignum-1.3) | [`BSD-3-Clause`](http://hackage.haskell.org/package/ghc-bignum-1.3/src/LICENSE) | GHC BigNum library | `base`, `bitvec`, `hashable`, `integer-gmp`, `integer-logarithms` | +| **`ghc-boot-th`** | [`9.6.3`](http://hackage.haskell.org/package/ghc-boot-th-9.6.3) | [`BSD-3-Clause`](http://hackage.haskell.org/package/ghc-boot-th-9.6.3/src/LICENSE) | Shared functionality between GHC and the @template-haskell@ library | `template-haskell` | +| **`ghc-prim`** | [`0.10.0`](http://hackage.haskell.org/package/ghc-prim-0.10.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/ghc-prim-0.10.0/src/LICENSE) | GHC primitives | *(core library)* | +| `generically` | [`0.1.1`](http://hackage.haskell.org/package/generically-0.1.1) | [`BSD-3-Clause`](./licences/haskell/generically-0.1.1/LICENSE) | Generically newtype to use with DerivingVia | `aeson` | +| `hourglass` | [`0.2.12`](http://hackage.haskell.org/package/hourglass-0.2.12) | [`BSD-3-Clause`](./licences/haskell/hourglass-0.2.12/LICENSE) | simple performant time related library | `asn1-encoding`, `asn1-types`, `crypton-x509`, `crypton-x509-validation`, `cryptostore`, `simplexmq` | +| `hashable` | [`1.4.3.0`](http://hackage.haskell.org/package/hashable-1.4.3.0) | [`BSD-3-Clause`](./licences/haskell/hashable-1.4.3.0/LICENSE) | A class for types that can be converted to a hash value | `aeson`, `async`, `case-insensitive`, `constraints`, `data-fix`, `mono-traversable`, `network-transport`, `psqueues`, `scientific`, `semialign`, `semigroupoids`, `simplexmq`, `strict`, `text-short`, `these`, `time-compat`, `unordered-containers`, `uuid-types`, `witherable` | +| **`integer-gmp`** | [`1.1`](http://hackage.haskell.org/package/integer-gmp-1.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/integer-gmp-1.1/src/LICENSE) | Integer library based on GMP | *(core library)* | +| `libyaml` | [`0.1.2`](http://hackage.haskell.org/package/libyaml-0.1.2) | [`BSD-3-Clause`](./licences/haskell/libyaml-0.1.2/LICENSE) | Low-level, streaming YAML interface. | `yaml` | +| `old-locale` | [`1.0.0.7`](http://hackage.haskell.org/package/old-locale-1.0.0.7) | [`BSD-3-Clause`](./licences/haskell/old-locale-1.0.0.7/LICENSE) | locale library | `blaze-textual`, `data-default-instances-old-locale`, `old-time` | +| `old-time` | [`1.1.0.4`](http://hackage.haskell.org/package/old-time-1.1.0.4) | [`BSD-3-Clause`](./licences/haskell/old-time-1.1.0.4/LICENSE) | Time library | `unix-time` | +| `lifted-base` | [`0.2.3.12`](http://hackage.haskell.org/package/lifted-base-0.2.3.12) | [`BSD-3-Clause`](./licences/haskell/lifted-base-0.2.3.12/LICENSE) | lifted IO operations from the base library | `monad-logger` | +| `mono-traversable` | [`1.0.15.3`](http://hackage.haskell.org/package/mono-traversable-1.0.15.3) | [`MIT`](./licences/haskell/mono-traversable-1.0.15.3/LICENSE) | Type classes for mapping, folding, and traversing monomorphic containers | `conduit` | +| `monad-control` | [`1.0.3.1`](http://hackage.haskell.org/package/monad-control-1.0.3.1) | [`BSD-3-Clause`](./licences/haskell/monad-control-1.0.3.1/LICENSE) | Lift control operations, like exception catching, through monad transformers | `lifted-base`, `monad-logger`, `zip` | +| `monad-logger` | [`0.3.40`](http://hackage.haskell.org/package/monad-logger-0.3.40) | [`MIT`](./licences/haskell/monad-logger-0.3.40/LICENSE) | A class of monads which can log messages. | `simple-logger` | +| `monad-loops` | [`0.4.3`](http://hackage.haskell.org/package/monad-loops-0.4.3) | [`LicenseRef-PublicDomain`](http://hackage.haskell.org/package/monad-loops-0.4.3) | Monadic loops | `monad-logger` | +| `ini` | [`0.4.2`](http://hackage.haskell.org/package/ini-0.4.2) | [`BSD-3-Clause`](./licences/haskell/ini-0.4.2/LICENSE) | Configuration files in the INI format. | `simplexmq` | +| `indexed-traversable` | [`0.1.3`](http://hackage.haskell.org/package/indexed-traversable-0.1.3) | [`BSD-2-Clause`](./licences/haskell/indexed-traversable-0.1.3/LICENSE) | FunctorWithIndex, FoldableWithIndex, TraversableWithIndex | `aeson`, `comonad`, `indexed-traversable-instances`, `semialign`, `witherable` | +| `indexed-traversable-instances` | [`0.1.1.2`](http://hackage.haskell.org/package/indexed-traversable-instances-0.1.1.2) | [`BSD-2-Clause`](./licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE) | More instances of FunctorWithIndex, FoldableWithIndex, TraversableWithIndex | `semialign`, `witherable` | +| `unordered-containers` | [`0.2.19.1`](http://hackage.haskell.org/package/unordered-containers-0.2.19.1) | [`BSD-3-Clause`](./licences/haskell/unordered-containers-0.2.19.1/LICENSE) | Efficient hashing-based container types | `aeson`, `indexed-traversable-instances`, `ini`, `mono-traversable`, `semialign`, `semigroupoids`, `witherable`, `yaml` | +| `ansi-terminal-types` | [`0.11.5`](http://hackage.haskell.org/package/ansi-terminal-types-0.11.5) | [`BSD-3-Clause`](./licences/haskell/ansi-terminal-types-0.11.5/LICENSE) | Types and functions used to represent SGR aspects | `ansi-terminal` | +| `integer-conversion` | [`0.1.0.1`](http://hackage.haskell.org/package/integer-conversion-0.1.0.1) | [`BSD-3-Clause`](./licences/haskell/integer-conversion-0.1.0.1/LICENSE) | Conversion from strings to Integer | `aeson`, `text-iso8601` | +| `integer-logarithms` | [`1.0.3.1`](http://hackage.haskell.org/package/integer-logarithms-1.0.3.1) | [`MIT`](./licences/haskell/integer-logarithms-1.0.3.1/LICENSE) | Integer logarithms. | `aeson`, `scientific` | +| `entropy` | [`0.4.1.10`](http://hackage.haskell.org/package/entropy-0.4.1.10) | [`BSD-3-Clause`](./licences/haskell/entropy-0.4.1.10/LICENSE) | A platform independent entropy source | `uuid`, `websockets` | +| `network-byte-order` | [`0.1.7`](http://hackage.haskell.org/package/network-byte-order-0.1.7) | [`BSD-3-Clause`](./licences/haskell/network-byte-order-0.1.7/LICENSE) | Network byte order utilities | `http2` | +| `network-control` | [`0.0.2`](http://hackage.haskell.org/package/network-control-0.0.2) | [`BSD-3-Clause`](./licences/haskell/network-control-0.0.2/LICENSE) | Library to control network protocols | `http2` | +| `network-udp` | [`0.0.0`](http://hackage.haskell.org/package/network-udp-0.0.0) | [`BSD-3-Clause`](./licences/haskell/network-udp-0.0.0/LICENSE) | UDP library | `simplexmq` | +| `network-info` | [`0.2.1`](http://hackage.haskell.org/package/network-info-0.2.1) | [`BSD-3-Clause`](./licences/haskell/network-info-0.2.1/LICENSE) | Access the local computer's basic network configuration | `simplexmq`, `uuid` | +| `network-uri` | [`2.6.4.2`](http://hackage.haskell.org/package/network-uri-2.6.4.2) | [`BSD-3-Clause`](./licences/haskell/network-uri-2.6.4.2/LICENSE) | URI manipulation | `aeson` | +| `unix-compat` | [`0.7.1`](http://hackage.haskell.org/package/unix-compat-0.7.1) | [`BSD-3-Clause`](./licences/haskell/unix-compat-0.7.1/LICENSE) | Portable POSIX-compatibility layer. | `fast-logger` | +| `unix-time` | [`0.4.11`](http://hackage.haskell.org/package/unix-time-0.4.11) | [`BSD-3-Clause`](./licences/haskell/unix-time-0.4.11/LICENSE) | Unix time parser/formatter and utilities | `fast-logger`, `http2`, `network-control`, `tls` | +| **`parsec`** | [`3.1.16.1`](http://hackage.haskell.org/package/parsec-3.1.16.1) | [`BSD-2-Clause`](http://hackage.haskell.org/package/parsec-3.1.16.1/src/LICENSE) | Monadic parser combinators | `network-uri` | +| `pem` | [`0.2.4`](http://hackage.haskell.org/package/pem-0.2.4) | [`BSD-3-Clause`](./licences/haskell/pem-0.2.4/LICENSE) | Privacy Enhanced Mail (PEM) format reader and writer. | `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore` | +| `appar` | [`0.1.8`](http://hackage.haskell.org/package/appar-0.1.8) | [`BSD-3-Clause`](./licences/haskell/appar-0.1.8/LICENSE) | A simple applicative parser | `iproute` | +| **`pretty`** | [`1.1.3.6`](http://hackage.haskell.org/package/pretty-1.1.3.6) | [`BSD-3-Clause`](http://hackage.haskell.org/package/pretty-1.1.3.6/src/LICENSE) | Pretty-printing library | `template-haskell` | +| `primitive` | [`0.9.0.0`](http://hackage.haskell.org/package/primitive-0.9.0.0) | [`BSD-3-Clause`](./licences/haskell/primitive-0.9.0.0/LICENSE) | Primitive memory-related operations | `aeson`, `bitvec`, `conduit`, `conduit-extra`, `integer-conversion`, `resourcet`, `scientific`, `vector`, `vector-algorithms` | +| `iproute` | [`1.7.12`](http://hackage.haskell.org/package/iproute-1.7.12) | [`BSD-3-Clause`](./licences/haskell/iproute-1.7.12/LICENSE) | IP Routing Table | `network-udp`, `simplexmq` | +| `prettyprinter` | [`1.7.1`](http://hackage.haskell.org/package/prettyprinter-1.7.1) | [`BSD-2-Clause`](./licences/haskell/prettyprinter-1.7.1/LICENSE.md) | A modern, easy to use, well-documented, extensible pretty-printer. | `optparse-applicative`, `prettyprinter-ansi-terminal`, `terminal` | +| `prettyprinter-ansi-terminal` | [`1.1.3`](http://hackage.haskell.org/package/prettyprinter-ansi-terminal-1.1.3) | [`BSD-2-Clause`](./licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md) | ANSI terminal backend for the »prettyprinter« package. | `optparse-applicative` | +| `psqueues` | [`0.2.8.0`](http://hackage.haskell.org/package/psqueues-0.2.8.0) | [`BSD-3-Clause`](./licences/haskell/psqueues-0.2.8.0/LICENSE) | Pure priority search queues | `network-control` | +| `resourcet` | [`1.3.0`](http://hackage.haskell.org/package/resourcet-1.3.0) | [`BSD-3-Clause`](./licences/haskell/resourcet-1.3.0/LICENSE) | Deterministic allocation and freeing of scarce resources. | `conduit`, `conduit-extra`, `libyaml`, `monad-logger`, `yaml`, `zip` | +| `iso8601-time` | [`0.1.5`](http://hackage.haskell.org/package/iso8601-time-0.1.5) | [`MIT`](http://hackage.haskell.org/package/iso8601-time-0.1.5) | Convert to/from the ISO 8601 time format | `simplexmq` | +| `safe-exceptions` | [`0.1.7.4`](http://hackage.haskell.org/package/safe-exceptions-0.1.7.4) | [`MIT`](./licences/haskell/safe-exceptions-0.1.7.4/LICENSE) | Safe, consistent, and easy exception handling | `unliftio` | +| `semigroupoids` | [`6.0.0.1`](http://hackage.haskell.org/package/semigroupoids-6.0.0.1) | [`BSD-2-Clause`](./licences/haskell/semigroupoids-6.0.0.1/LICENSE) | Semigroupoids: Category sans id | `semialign` | +| `semialign` | [`1.3`](http://hackage.haskell.org/package/semialign-1.3) | [`BSD-3-Clause`](./licences/haskell/semialign-1.3/LICENSE) | Align and Zip type-classes from the common Semialign ancestor. | `aeson` | +| `asn1-encoding` | [`0.9.6`](http://hackage.haskell.org/package/asn1-encoding-0.9.6) | [`BSD-3-Clause`](./licences/haskell/asn1-encoding-0.9.6/LICENSE) | ASN1 data reader and writer in RAW, BER and DER forms | `asn1-parse`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `asn1-parse` | [`0.9.5`](http://hackage.haskell.org/package/asn1-parse-0.9.5) | [`BSD-3-Clause`](./licences/haskell/asn1-parse-0.9.5/LICENSE) | Simple monadic parser for ASN1 stream types. | `crypton-x509` | +| `asn1-types` | [`0.3.4`](http://hackage.haskell.org/package/asn1-types-0.3.4) | [`BSD-3-Clause`](./licences/haskell/asn1-types-0.3.4/LICENSE) | ASN.1 types | `asn1-encoding`, `asn1-parse`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `split` | [`0.2.4`](http://hackage.haskell.org/package/split-0.2.4) | [`BSD-3-Clause`](./licences/haskell/split-0.2.4/LICENSE) | Combinator library for splitting lists. | `mono-traversable` | +| `splitmix` | [`0.1.0.5`](http://hackage.haskell.org/package/splitmix-0.1.0.5) | [`BSD-3-Clause`](./licences/haskell/splitmix-0.1.0.5/LICENSE) | Fast Splittable PRNG | `QuickCheck`, `random` | +| `assoc` | [`1.1`](http://hackage.haskell.org/package/assoc-1.1) | [`BSD-3-Clause`](./licences/haskell/assoc-1.1/LICENSE) | swap and assoc: Symmetric and Semigroupy Bifunctors | `bifunctors`, `strict`, `these` | +| `stm-chans` | [`3.0.0.9`](http://hackage.haskell.org/package/stm-chans-3.0.0.9) | [`BSD-3-Clause`](./licences/haskell/stm-chans-3.0.0.9/LICENSE) | Additional types of channels for STM. | `monad-logger` | +| `strict` | [`0.5`](http://hackage.haskell.org/package/strict-0.5) | [`BSD-3-Clause`](./licences/haskell/strict-0.5/LICENSE) | Strict data types and String IO. | `aeson` | +| `streaming-commons` | [`0.2.2.6`](http://hackage.haskell.org/package/streaming-commons-0.2.2.6) | [`MIT`](./licences/haskell/streaming-commons-0.2.2.6/LICENSE) | Common lower-level functions needed by various streaming data libraries | `conduit-extra`, `websockets` | +| `easy-file` | [`0.2.5`](http://hackage.haskell.org/package/easy-file-0.2.5) | [`BSD-3-Clause`](./licences/haskell/easy-file-0.2.5/LICENSE) | Cross-platform File handling | `fast-logger` | +| `auto-update` | [`0.1.6`](http://hackage.haskell.org/package/auto-update-0.1.6) | [`MIT`](./licences/haskell/auto-update-0.1.6/LICENSE) | Efficiently run periodic, on-demand actions | `fast-logger`, `time-manager` | +| `tagged` | [`0.8.8`](http://hackage.haskell.org/package/tagged-0.8.8) | [`BSD-3-Clause`](./licences/haskell/tagged-0.8.8/LICENSE) | Haskell 98 phantom types to avoid unsafely passing dummy arguments | `aeson`, `assoc`, `bifunctors`, `boring`, `comonad`, `distributive`, `indexed-traversable-instances`, `semialign`, `semigroupoids` | +| `th-abstraction` | [`0.6.0.0`](http://hackage.haskell.org/package/th-abstraction-0.6.0.0) | [`ISC`](./licences/haskell/th-abstraction-0.6.0.0/LICENSE) | Nicer interface for reified information about data types | `aeson`, `bifunctors` | +| `th-compat` | [`0.1.4`](http://hackage.haskell.org/package/th-compat-0.1.4) | [`BSD-3-Clause`](./licences/haskell/th-compat-0.1.4/LICENSE) | Backward- (and forward-)compatible Quote and Code types | `network-uri` | +| `these` | [`1.2`](http://hackage.haskell.org/package/these-1.2) | [`BSD-3-Clause`](./licences/haskell/these-1.2/LICENSE) | An either-or-both data type. | `aeson`, `semialign`, `strict` | +| `time-compat` | [`1.9.6.1`](http://hackage.haskell.org/package/time-compat-1.9.6.1) | [`BSD-3-Clause`](./licences/haskell/time-compat-1.9.6.1/LICENSE) | Compatibility package for time | `aeson`, `text-iso8601` | +| `time-manager` | [`0.0.1`](http://hackage.haskell.org/package/time-manager-0.0.1) | [`MIT`](./licences/haskell/time-manager-0.0.1/LICENSE) | Scalable timer | `http2`, `simplexmq` | +| `temporary` | [`1.3`](http://hackage.haskell.org/package/temporary-1.3) | [`BSD-3-Clause`](./licences/haskell/temporary-1.3/LICENSE) | Portable temporary file and directory support | `simplexmq` | +| **`transformers`** | [`0.6.1.0`](http://hackage.haskell.org/package/transformers-0.6.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/transformers-0.6.1.0/src/LICENSE) | Concrete functor and monad transformers | `QuickCheck`, `StateVar`, `attoparsec`, `bifunctors`, `boring`, `comonad`, `conduit`, `conduit-extra`, `constraints`, `contravariant`, `crypton-x509`, `distributive`, `exceptions`, `indexed-traversable`, `monad-control`, `monad-logger`, `mono-traversable`, `mtl`, `network-transport`, `optparse-applicative`, `primitive`, `resourcet`, `safe-exceptions`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `streaming-commons`, `strict`, `tagged`, `temporary`, `terminal`, `tls`, `transformers-base`, `transformers-compat`, `typed-process`, `unliftio`, `unliftio-core`, `witherable`, `yaml`, `zip` | +| `transformers-base` | [`0.4.6`](http://hackage.haskell.org/package/transformers-base-0.4.6) | [`BSD-3-Clause`](./licences/haskell/transformers-base-0.4.6/LICENSE) | Lift computations from the bottom of a transformer stack | `lifted-base`, `monad-control`, `monad-logger`, `zip` | +| `transformers-compat` | [`0.7.2`](http://hackage.haskell.org/package/transformers-compat-0.7.2) | [`BSD-3-Clause`](./licences/haskell/transformers-compat-0.7.2/LICENSE) | A small compatibility shim for the transformers library | `comonad`, `monad-control`, `monad-logger`, `optparse-applicative`, `semigroupoids`, `transformers-base` | +| `attoparsec` | [`0.14.4`](http://hackage.haskell.org/package/attoparsec-0.14.4) | [`BSD-3-Clause`](./licences/haskell/attoparsec-0.14.4/LICENSE) | Fast combinator parsing for bytestrings and text | `attoparsec` | +| `text-iso8601` | [`0.1`](http://hackage.haskell.org/package/text-iso8601-0.1) | [`BSD-3-Clause`](./licences/haskell/text-iso8601-0.1/LICENSE) | Converting time to and from ISO 8601 text. | `aeson` | +| `text-short` | [`0.1.5`](http://hackage.haskell.org/package/text-short-0.1.5) | [`BSD-3-Clause`](./licences/haskell/text-short-0.1.5/LICENSE) | Memory-efficient representation of Unicode text strings | `aeson` | +| `type-equality` | [`1`](http://hackage.haskell.org/package/type-equality-1) | [`BSD-3-Clause`](./licences/haskell/type-equality-1/LICENSE) | Data.Type.Equality compat package | `constraints` | +| `typed-process` | [`0.2.11.1`](http://hackage.haskell.org/package/typed-process-0.2.11.1) | [`MIT`](./licences/haskell/typed-process-0.2.11.1/LICENSE) | Run external processes, with strong typing of streams | `conduit-extra` | +| **`unix`** | [`2.8.1.0`](http://hackage.haskell.org/package/unix-2.8.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/unix-2.8.1.0/src/LICENSE) | POSIX functionality | `conduit`, `directory`, `easy-file`, `entropy`, `process`, `streaming-commons`, `temporary`, `unix-compat`, `unliftio`, `zip` | +| `vector` | [`0.13.1.0`](http://hackage.haskell.org/package/vector-0.13.1.0) | [`BSD-3-Clause`](./licences/haskell/vector-0.13.1.0/LICENSE) | Efficient Arrays | `aeson`, `bitvec`, `blaze-textual`, `conduit`, `indexed-traversable-instances`, `mono-traversable`, `semialign`, `vector-algorithms`, `witherable`, `yaml` | +| `vector-algorithms` | [`0.9.0.1`](http://hackage.haskell.org/package/vector-algorithms-0.9.0.1) | [`BSD-3-Clause`](./licences/haskell/vector-algorithms-0.9.0.1/LICENSE) | Efficient algorithms for vector arrays | `mono-traversable` | +| `vector-stream` | [`0.1.0.0`](http://hackage.haskell.org/package/vector-stream-0.1.0.0) | [`BSD-3-Clause`](./licences/haskell/vector-stream-0.1.0.0/LICENSE) | Efficient Streams | `vector` | +| `witherable` | [`0.4.2`](http://hackage.haskell.org/package/witherable-0.4.2) | [`BSD-3-Clause`](./licences/haskell/witherable-0.4.2/LICENSE) | filterable traversable | `aeson` | +| `yaml` | [`0.11.11.2`](http://hackage.haskell.org/package/yaml-0.11.11.2) | [`BSD-3-Clause`](./licences/haskell/yaml-0.11.11.2/LICENSE) | Support for parsing and rendering YAML documents. | `simplexmq` | +| `zlib` | [`0.6.3.0`](http://hackage.haskell.org/package/zlib-0.6.3.0) | [`BSD-3-Clause`](./licences/haskell/zlib-0.6.3.0/LICENSE) | Compression and decompression in the gzip and zlib formats | `streaming-commons` | +| `zstd` | [`0.1.3.0`](http://hackage.haskell.org/package/zstd-0.1.3.0) | [`BSD-3-Clause`](./licences/haskell/zstd-0.1.3.0/LICENSE) | Haskell bindings to the Zstandard compression algorithm | `simplexmq` | + diff --git a/docs/dependencies/README.md b/docs/dependencies/README.md new file mode 100644 index 0000000000..bec369c126 --- /dev/null +++ b/docs/dependencies/README.md @@ -0,0 +1,9 @@ +# SimpleX Chat and SimpleX servers dependencies + +[SQLCipher](https://github.com/sqlcipher/sqlcipher): Extension of [SQLite](https://sqlite.org) with encryption ([BSD-style](./licences/apps/sqlcipher/LICENSE.md)) + +[vlc](https://github.com/videolan/vlc): VLC media player library ([LGPLv2](./licences/apps/vlc/COPYING.LIB)) + +[WebRTC](https://webrtc.googlesource.com/src/): RTC for calls ([BSD-3-clause](./licences/apps/webrtc/LICENSE)) + +[Haskell dependencies](./HASKELL.md). diff --git a/docs/dependencies/licences/apps/sqlcipher/LICENSE.md b/docs/dependencies/licences/apps/sqlcipher/LICENSE.md new file mode 100644 index 0000000000..3f71443161 --- /dev/null +++ b/docs/dependencies/licences/apps/sqlcipher/LICENSE.md @@ -0,0 +1,24 @@ +Copyright (c) 2025, ZETETIC LLC +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the ZETETIC LLC nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY ZETETIC LLC ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/apps/vlc/COPYING.LIB b/docs/dependencies/licences/apps/vlc/COPYING.LIB new file mode 100644 index 0000000000..4362b49151 --- /dev/null +++ b/docs/dependencies/licences/apps/vlc/COPYING.LIB @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/docs/dependencies/licences/apps/webrtc/LICENSE b/docs/dependencies/licences/apps/webrtc/LICENSE new file mode 100644 index 0000000000..8868dcdf45 --- /dev/null +++ b/docs/dependencies/licences/apps/webrtc/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2011, The WebRTC project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/OneTuple-0.4.1.1/LICENSE b/docs/dependencies/licences/haskell/OneTuple-0.4.1.1/LICENSE new file mode 100644 index 0000000000..405f9161e9 --- /dev/null +++ b/docs/dependencies/licences/haskell/OneTuple-0.4.1.1/LICENSE @@ -0,0 +1,34 @@ + +Copyright (c) 2008, John A. Dorsey. +All rights reserved. + +Redistribution and use of this software in source and binary forms, +with or without modification, are permitted provided that the +following conditions are met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +* Neither the name of John Dorsey nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/Only-0.1/LICENSE b/docs/dependencies/licences/haskell/Only-0.1/LICENSE new file mode 100644 index 0000000000..98d4b1ad78 --- /dev/null +++ b/docs/dependencies/licences/haskell/Only-0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Herbert Valerio Riedel nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/QuickCheck-2.14.3/LICENSE b/docs/dependencies/licences/haskell/QuickCheck-2.14.3/LICENSE new file mode 100644 index 0000000000..8b0fdbfe4e --- /dev/null +++ b/docs/dependencies/licences/haskell/QuickCheck-2.14.3/LICENSE @@ -0,0 +1,29 @@ +(The following is the 3-clause BSD license.) + +Copyright (c) 2000-2019, Koen Claessen +Copyright (c) 2006-2008, Björn Bringert +Copyright (c) 2009-2019, Nick Smallbone + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +- Neither the names of the copyright owners nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/SHA-1.6.4.4/LICENSE b/docs/dependencies/licences/haskell/SHA-1.6.4.4/LICENSE new file mode 100644 index 0000000000..8e49100207 --- /dev/null +++ b/docs/dependencies/licences/haskell/SHA-1.6.4.4/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2008, Galois, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the Galois, Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/StateVar-1.2.2/LICENSE b/docs/dependencies/licences/haskell/StateVar-1.2.2/LICENSE new file mode 100644 index 0000000000..e71299ba0e --- /dev/null +++ b/docs/dependencies/licences/haskell/StateVar-1.2.2/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2014-2015, Edward Kmett +Copyright (c) 2009-2021, Sven Panne +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/aeson-2.2.1.0/LICENSE b/docs/dependencies/licences/haskell/aeson-2.2.1.0/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/ansi-terminal-1.0/LICENSE b/docs/dependencies/licences/haskell/ansi-terminal-1.0/LICENSE new file mode 100644 index 0000000000..814a83d4fc --- /dev/null +++ b/docs/dependencies/licences/haskell/ansi-terminal-1.0/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2008, Maximilian Bolingbroke +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted +provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + * Neither the name of Maximilian Bolingbroke nor the names of other contributors may be used to + endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/ansi-terminal-types-0.11.5/LICENSE b/docs/dependencies/licences/haskell/ansi-terminal-types-0.11.5/LICENSE new file mode 100644 index 0000000000..413047009d --- /dev/null +++ b/docs/dependencies/licences/haskell/ansi-terminal-types-0.11.5/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2008, Maximilian Bolingbroke +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Maximilian Bolingbroke nor the names of other contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/appar-0.1.8/LICENSE b/docs/dependencies/licences/haskell/appar-0.1.8/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/appar-0.1.8/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/asn1-encoding-0.9.6/LICENSE b/docs/dependencies/licences/haskell/asn1-encoding-0.9.6/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/asn1-encoding-0.9.6/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/asn1-parse-0.9.5/LICENSE b/docs/dependencies/licences/haskell/asn1-parse-0.9.5/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/asn1-parse-0.9.5/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/asn1-types-0.3.4/LICENSE b/docs/dependencies/licences/haskell/asn1-types-0.3.4/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/asn1-types-0.3.4/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/assoc-1.1/LICENSE b/docs/dependencies/licences/haskell/assoc-1.1/LICENSE new file mode 100644 index 0000000000..96445e5484 --- /dev/null +++ b/docs/dependencies/licences/haskell/assoc-1.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/async-2.2.5/LICENSE b/docs/dependencies/licences/haskell/async-2.2.5/LICENSE new file mode 100644 index 0000000000..0acbb121f4 --- /dev/null +++ b/docs/dependencies/licences/haskell/async-2.2.5/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, Simon Marlow + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Simon Marlow nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/attoparsec-0.14.4/LICENSE b/docs/dependencies/licences/haskell/attoparsec-0.14.4/LICENSE new file mode 100644 index 0000000000..97392a6264 --- /dev/null +++ b/docs/dependencies/licences/haskell/attoparsec-0.14.4/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) Lennart Kolmodin + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/auto-update-0.1.6/LICENSE b/docs/dependencies/licences/haskell/auto-update-0.1.6/LICENSE new file mode 100644 index 0000000000..d2a7d05797 --- /dev/null +++ b/docs/dependencies/licences/haskell/auto-update-0.1.6/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2014 Michael Snoyman + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/base-orphans-0.9.1/LICENSE b/docs/dependencies/licences/haskell/base-orphans-0.9.1/LICENSE new file mode 100644 index 0000000000..4751ca3f63 --- /dev/null +++ b/docs/dependencies/licences/haskell/base-orphans-0.9.1/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015-2017 Simon Hengel , João Cristóvão , Ryan Scott + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/base64-bytestring-1.2.1.0/LICENSE b/docs/dependencies/licences/haskell/base64-bytestring-1.2.1.0/LICENSE new file mode 100644 index 0000000000..89d2a1b35f --- /dev/null +++ b/docs/dependencies/licences/haskell/base64-bytestring-1.2.1.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2010 Bryan O'Sullivan + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/basement-0.0.16/LICENSE b/docs/dependencies/licences/haskell/basement-0.0.16/LICENSE new file mode 100644 index 0000000000..75e0288c26 --- /dev/null +++ b/docs/dependencies/licences/haskell/basement-0.0.16/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2015-2017 Vincent Hanquez +Copyright (c) 2017-2019 Foundation Maintainers + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/bifunctors-5.6.1/LICENSE b/docs/dependencies/licences/haskell/bifunctors-5.6.1/LICENSE new file mode 100644 index 0000000000..531684e87f --- /dev/null +++ b/docs/dependencies/licences/haskell/bifunctors-5.6.1/LICENSE @@ -0,0 +1,26 @@ +Copyright 2008-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/bitvec-1.1.5.0/LICENSE b/docs/dependencies/licences/haskell/bitvec-1.1.5.0/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/blaze-builder-0.4.2.3/LICENSE b/docs/dependencies/licences/haskell/blaze-builder-0.4.2.3/LICENSE new file mode 100644 index 0000000000..a8bcabfd42 --- /dev/null +++ b/docs/dependencies/licences/haskell/blaze-builder-0.4.2.3/LICENSE @@ -0,0 +1,30 @@ +Copyright Jasper Van der Jeugt 2010, Simon Meier 2010 & 2011 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Jasper Van der Jeugt nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/blaze-textual-0.2.3.1/LICENSE b/docs/dependencies/licences/haskell/blaze-textual-0.2.3.1/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/boring-0.2.1/LICENSE b/docs/dependencies/licences/haskell/boring-0.2.1/LICENSE new file mode 100644 index 0000000000..96445e5484 --- /dev/null +++ b/docs/dependencies/licences/haskell/boring-0.2.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/byteorder-1.0.4/LICENSE b/docs/dependencies/licences/haskell/byteorder-1.0.4/LICENSE new file mode 100644 index 0000000000..81b771a3b8 --- /dev/null +++ b/docs/dependencies/licences/haskell/byteorder-1.0.4/LICENSE @@ -0,0 +1,30 @@ +Copyright 2009, Antoine Latter + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the author nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE b/docs/dependencies/licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE new file mode 100644 index 0000000000..5106f1309a --- /dev/null +++ b/docs/dependencies/licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Jasper Van der Jeugt 2010, Simon Meier 2010-2013 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Jasper Van der Jeugt nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/case-insensitive-1.2.1.0/LICENSE b/docs/dependencies/licences/haskell/case-insensitive-1.2.1.0/LICENSE new file mode 100644 index 0000000000..c81fb8bf9d --- /dev/null +++ b/docs/dependencies/licences/haskell/case-insensitive-1.2.1.0/LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2011-2013 Bas van Dijk + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * The name of Bas van Dijk and the names of contributors may NOT + be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cereal-0.5.8.3/LICENSE b/docs/dependencies/licences/haskell/cereal-0.5.8.3/LICENSE new file mode 100644 index 0000000000..6dfb133efb --- /dev/null +++ b/docs/dependencies/licences/haskell/cereal-0.5.8.3/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) Lennart Kolmodin, Galois, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/clock-0.8.4/LICENSE b/docs/dependencies/licences/haskell/clock-0.8.4/LICENSE new file mode 100644 index 0000000000..c2451c8c0a --- /dev/null +++ b/docs/dependencies/licences/haskell/clock-0.8.4/LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2009-2022, Clock Contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * The names of contributors may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/colour-2.3.6/LICENSE b/docs/dependencies/licences/haskell/colour-2.3.6/LICENSE new file mode 100644 index 0000000000..61aede888a --- /dev/null +++ b/docs/dependencies/licences/haskell/colour-2.3.6/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2008, 2009 +Russell O'Connor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/comonad-5.0.8/LICENSE b/docs/dependencies/licences/haskell/comonad-5.0.8/LICENSE new file mode 100644 index 0000000000..f8e92e04df --- /dev/null +++ b/docs/dependencies/licences/haskell/comonad-5.0.8/LICENSE @@ -0,0 +1,27 @@ +Copyright 2008-2014 Edward Kmett +Copyright 2004-2008 Dave Menendez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/composition-1.0.2.2/LICENSE b/docs/dependencies/licences/haskell/composition-1.0.2.2/LICENSE new file mode 100644 index 0000000000..e191e7ab64 --- /dev/null +++ b/docs/dependencies/licences/haskell/composition-1.0.2.2/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2011-2012, Dan Burton + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Dan Burton nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/conduit-1.3.5/LICENSE b/docs/dependencies/licences/haskell/conduit-1.3.5/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/conduit-extra-1.3.6/LICENSE b/docs/dependencies/licences/haskell/conduit-extra-1.3.6/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/constraints-0.14/LICENSE b/docs/dependencies/licences/haskell/constraints-0.14/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/contravariant-1.5.5/LICENSE b/docs/dependencies/licences/haskell/contravariant-1.5.5/LICENSE new file mode 100644 index 0000000000..f357c7c429 --- /dev/null +++ b/docs/dependencies/licences/haskell/contravariant-1.5.5/LICENSE @@ -0,0 +1,30 @@ +Copyright 2007-2015 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cryptohash-md5-0.11.101.0/LICENSE b/docs/dependencies/licences/haskell/cryptohash-md5-0.11.101.0/LICENSE new file mode 100644 index 0000000000..467bf18f27 --- /dev/null +++ b/docs/dependencies/licences/haskell/cryptohash-md5-0.11.101.0/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2010-2014 Vincent Hanquez + 2016 Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE b/docs/dependencies/licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE new file mode 100644 index 0000000000..467bf18f27 --- /dev/null +++ b/docs/dependencies/licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2010-2014 Vincent Hanquez + 2016 Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-0.34/LICENSE b/docs/dependencies/licences/haskell/crypton-0.34/LICENSE new file mode 100644 index 0000000000..a61c84945b --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-0.34/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2006-2015 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-x509-1.7.6/LICENSE b/docs/dependencies/licences/haskell/crypton-x509-1.7.6/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-x509-1.7.6/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-x509-store-1.6.9/LICENSE b/docs/dependencies/licences/haskell/crypton-x509-store-1.6.9/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-x509-store-1.6.9/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-x509-validation-1.6.12/LICENSE b/docs/dependencies/licences/haskell/crypton-x509-validation-1.6.12/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-x509-validation-1.6.12/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cryptostore-0.3.0.1/LICENSE b/docs/dependencies/licences/haskell/cryptostore-0.3.0.1/LICENSE new file mode 100644 index 0000000000..b85926a265 --- /dev/null +++ b/docs/dependencies/licences/haskell/cryptostore-0.3.0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2018-2023, Olivier Chéron + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Olivier Chéron nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-0.7.1.1/LICENSE b/docs/dependencies/licences/haskell/data-default-0.7.1.1/LICENSE new file mode 100644 index 0000000000..cd44640be9 --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-0.7.1.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-class-0.1.2.0/LICENSE b/docs/dependencies/licences/haskell/data-default-class-0.1.2.0/LICENSE new file mode 100644 index 0000000000..cd44640be9 --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-class-0.1.2.0/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-instances-containers-0.0.1/LICENSE b/docs/dependencies/licences/haskell/data-default-instances-containers-0.0.1/LICENSE new file mode 100644 index 0000000000..4f9075d76b --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-instances-containers-0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY LUKAS MAI AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-instances-dlist-0.0.1/LICENSE b/docs/dependencies/licences/haskell/data-default-instances-dlist-0.0.1/LICENSE new file mode 100644 index 0000000000..4f9075d76b --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-instances-dlist-0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY LUKAS MAI AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE b/docs/dependencies/licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE new file mode 100644 index 0000000000..4f9075d76b --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY LUKAS MAI AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-fix-0.3.2/LICENSE b/docs/dependencies/licences/haskell/data-fix-0.3.2/LICENSE new file mode 100644 index 0000000000..0576170924 --- /dev/null +++ b/docs/dependencies/licences/haskell/data-fix-0.3.2/LICENSE @@ -0,0 +1,30 @@ +Copyright Anton Kholomiov 2010 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Anton Kholomiov nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/digest-0.0.1.7/LICENSE b/docs/dependencies/licences/haskell/digest-0.0.1.7/LICENSE new file mode 100644 index 0000000000..48326b0815 --- /dev/null +++ b/docs/dependencies/licences/haskell/digest-0.0.1.7/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2008-2009, Eugene Kirpichov +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/distributive-0.6.2.1/LICENSE b/docs/dependencies/licences/haskell/distributive-0.6.2.1/LICENSE new file mode 100644 index 0000000000..d6c9cbe4a4 --- /dev/null +++ b/docs/dependencies/licences/haskell/distributive-0.6.2.1/LICENSE @@ -0,0 +1,26 @@ +Copyright 2011-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/dlist-1.0/license.md b/docs/dependencies/licences/haskell/dlist-1.0/license.md new file mode 100644 index 0000000000..32ac978e90 --- /dev/null +++ b/docs/dependencies/licences/haskell/dlist-1.0/license.md @@ -0,0 +1,28 @@ +Copyright © 2006-2009 Don Stewart, 2013-2020 Sean Leather, contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holders nor the names of other contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/easy-file-0.2.5/LICENSE b/docs/dependencies/licences/haskell/easy-file-0.2.5/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/easy-file-0.2.5/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/email-validate-2.3.2.19/LICENSE b/docs/dependencies/licences/haskell/email-validate-2.3.2.19/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/entropy-0.4.1.10/LICENSE b/docs/dependencies/licences/haskell/entropy-0.4.1.10/LICENSE new file mode 100644 index 0000000000..6d24e89d0b --- /dev/null +++ b/docs/dependencies/licences/haskell/entropy-0.4.1.10/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) Thomas DuBuisson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/fast-logger-3.2.2/LICENSE b/docs/dependencies/licences/haskell/fast-logger-3.2.2/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/fast-logger-3.2.2/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/file-embed-0.0.15.0/LICENSE b/docs/dependencies/licences/haskell/file-embed-0.0.15.0/LICENSE new file mode 100644 index 0000000000..11dc17a16c --- /dev/null +++ b/docs/dependencies/licences/haskell/file-embed-0.0.15.0/LICENSE @@ -0,0 +1,25 @@ +The following license covers this documentation, and the source code, except +where otherwise indicated. + +Copyright 2008, Michael Snoyman. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/generically-0.1.1/LICENSE b/docs/dependencies/licences/haskell/generically-0.1.1/LICENSE new file mode 100644 index 0000000000..58c573a1e2 --- /dev/null +++ b/docs/dependencies/licences/haskell/generically-0.1.1/LICENSE @@ -0,0 +1,28 @@ +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Herbert Valerio Riedel nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/hashable-1.4.3.0/LICENSE b/docs/dependencies/licences/haskell/hashable-1.4.3.0/LICENSE new file mode 100644 index 0000000000..7130957b4d --- /dev/null +++ b/docs/dependencies/licences/haskell/hashable-1.4.3.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Milan Straka 2010 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Milan Straka nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/hourglass-0.2.12/LICENSE b/docs/dependencies/licences/haskell/hourglass-0.2.12/LICENSE new file mode 100644 index 0000000000..36c942d4a8 --- /dev/null +++ b/docs/dependencies/licences/haskell/hourglass-0.2.12/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2014 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/http-types-0.12.4/LICENSE b/docs/dependencies/licences/haskell/http-types-0.12.4/LICENSE new file mode 100644 index 0000000000..f77a7b24dd --- /dev/null +++ b/docs/dependencies/licences/haskell/http-types-0.12.4/LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2011, Aristid Breitkreuz +Copyright (c) 2011, Michael Snoyman + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Aristid Breitkreuz nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/http2-5.0.0/LICENSE b/docs/dependencies/licences/haskell/http2-5.0.0/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/indexed-traversable-0.1.3/LICENSE b/docs/dependencies/licences/haskell/indexed-traversable-0.1.3/LICENSE new file mode 100644 index 0000000000..476166addc --- /dev/null +++ b/docs/dependencies/licences/haskell/indexed-traversable-0.1.3/LICENSE @@ -0,0 +1,26 @@ +Copyright 2012-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE b/docs/dependencies/licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE new file mode 100644 index 0000000000..476166addc --- /dev/null +++ b/docs/dependencies/licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE @@ -0,0 +1,26 @@ +Copyright 2012-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/ini-0.4.2/LICENSE b/docs/dependencies/licences/haskell/ini-0.4.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/integer-conversion-0.1.0.1/LICENSE b/docs/dependencies/licences/haskell/integer-conversion-0.1.0.1/LICENSE new file mode 100644 index 0000000000..3f74492595 --- /dev/null +++ b/docs/dependencies/licences/haskell/integer-conversion-0.1.0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2023, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/integer-logarithms-1.0.3.1/LICENSE b/docs/dependencies/licences/haskell/integer-logarithms-1.0.3.1/LICENSE new file mode 100644 index 0000000000..9430f484d0 --- /dev/null +++ b/docs/dependencies/licences/haskell/integer-logarithms-1.0.3.1/LICENSE @@ -0,0 +1,16 @@ +Copyright (c) 2011 Daniel Fischer, 2017 Oleg Grenrus + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + associated documentation files (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, publish, distribute, + sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/iproute-1.7.12/LICENSE b/docs/dependencies/licences/haskell/iproute-1.7.12/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/iproute-1.7.12/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/libyaml-0.1.2/LICENSE b/docs/dependencies/licences/haskell/libyaml-0.1.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/lifted-base-0.2.3.12/LICENSE b/docs/dependencies/licences/haskell/lifted-base-0.2.3.12/LICENSE new file mode 100644 index 0000000000..b8b5ed95c0 --- /dev/null +++ b/docs/dependencies/licences/haskell/lifted-base-0.2.3.12/LICENSE @@ -0,0 +1,29 @@ +Copyright © 2010-2012, Bas van Dijk, Anders Kaseorg +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +• Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +• Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +• Neither the name of the author nor the names of other contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +“AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/memory-0.18.0/LICENSE b/docs/dependencies/licences/haskell/memory-0.18.0/LICENSE new file mode 100644 index 0000000000..8eafd52555 --- /dev/null +++ b/docs/dependencies/licences/haskell/memory-0.18.0/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2015-2018 Vincent Hanquez +Copyright (c) 2017-2018 Nicolas Di Prima + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/monad-control-1.0.3.1/LICENSE b/docs/dependencies/licences/haskell/monad-control-1.0.3.1/LICENSE new file mode 100644 index 0000000000..f4e1bfab44 --- /dev/null +++ b/docs/dependencies/licences/haskell/monad-control-1.0.3.1/LICENSE @@ -0,0 +1,29 @@ +Copyright © 2010, Bas van Dijk, Anders Kaseorg +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +• Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +• Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +• Neither the name of the author nor the names of other contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +“AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/monad-logger-0.3.40/LICENSE b/docs/dependencies/licences/haskell/monad-logger-0.3.40/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/mono-traversable-1.0.15.3/LICENSE b/docs/dependencies/licences/haskell/mono-traversable-1.0.15.3/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/network-3.1.4.0/LICENSE b/docs/dependencies/licences/haskell/network-3.1.4.0/LICENSE new file mode 100644 index 0000000000..40bba626f8 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-3.1.4.0/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2002-2010, The University Court of the University of Glasgow. +Copyright (c) 2007-2010, Johan Tibell + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-byte-order-0.1.7/LICENSE b/docs/dependencies/licences/haskell/network-byte-order-0.1.7/LICENSE new file mode 100644 index 0000000000..ed342e54a0 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-byte-order-0.1.7/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Kazu Yamamoto + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Kazu Yamamoto nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-control-0.0.2/LICENSE b/docs/dependencies/licences/haskell/network-control-0.0.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/network-info-0.2.1/LICENSE b/docs/dependencies/licences/haskell/network-info-0.2.1/LICENSE new file mode 100644 index 0000000000..be005bd412 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-info-0.2.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2010, Jacob Stanley + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Jacob Stanley nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-transport-0.5.6/LICENSE b/docs/dependencies/licences/haskell/network-transport-0.5.6/LICENSE new file mode 100644 index 0000000000..f3459e449d --- /dev/null +++ b/docs/dependencies/licences/haskell/network-transport-0.5.6/LICENSE @@ -0,0 +1,31 @@ +Copyright Well-Typed LLP, 2011-2012 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the owner nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/network-udp-0.0.0/LICENSE b/docs/dependencies/licences/haskell/network-udp-0.0.0/LICENSE new file mode 100644 index 0000000000..73957a7a32 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-udp-0.0.0/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2022, Internet Initiative Japan Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-uri-2.6.4.2/LICENSE b/docs/dependencies/licences/haskell/network-uri-2.6.4.2/LICENSE new file mode 100644 index 0000000000..40bba626f8 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-uri-2.6.4.2/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2002-2010, The University Court of the University of Glasgow. +Copyright (c) 2007-2010, Johan Tibell + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/old-locale-1.0.0.7/LICENSE b/docs/dependencies/licences/haskell/old-locale-1.0.0.7/LICENSE new file mode 100644 index 0000000000..06bb641487 --- /dev/null +++ b/docs/dependencies/licences/haskell/old-locale-1.0.0.7/LICENSE @@ -0,0 +1,63 @@ +This library (libraries/base) is derived from code from two +sources: + + * Code from the GHC project which is largely (c) The University of + Glasgow, and distributable under a BSD-style license (see below), + + * Code from the Haskell 98 Report which is (c) Simon Peyton Jones + and freely redistributable (but see the full license for + restrictions). + +The full text of these licenses is reproduced below. Both of the +licenses are BSD-style or compatible. + +----------------------------------------------------------------------------- + +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +----------------------------------------------------------------------------- + +Code derived from the document "Report on the Programming Language +Haskell 98", is distributed under the following license: + + Copyright (c) 2002 Simon Peyton Jones + + The authors intend this Report to belong to the entire Haskell + community, and so we grant permission to copy and distribute it for + any purpose, provided that it is reproduced in its entirety, + including this Notice. Modified versions of this Report may also be + copied and distributed for any purpose, provided that the modified + version is clearly presented as such, and that it does not claim to + be a definition of the Haskell 98 Language. + +----------------------------------------------------------------------------- diff --git a/docs/dependencies/licences/haskell/old-time-1.1.0.4/LICENSE b/docs/dependencies/licences/haskell/old-time-1.1.0.4/LICENSE new file mode 100644 index 0000000000..06bb641487 --- /dev/null +++ b/docs/dependencies/licences/haskell/old-time-1.1.0.4/LICENSE @@ -0,0 +1,63 @@ +This library (libraries/base) is derived from code from two +sources: + + * Code from the GHC project which is largely (c) The University of + Glasgow, and distributable under a BSD-style license (see below), + + * Code from the Haskell 98 Report which is (c) Simon Peyton Jones + and freely redistributable (but see the full license for + restrictions). + +The full text of these licenses is reproduced below. Both of the +licenses are BSD-style or compatible. + +----------------------------------------------------------------------------- + +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +----------------------------------------------------------------------------- + +Code derived from the document "Report on the Programming Language +Haskell 98", is distributed under the following license: + + Copyright (c) 2002 Simon Peyton Jones + + The authors intend this Report to belong to the entire Haskell + community, and so we grant permission to copy and distribute it for + any purpose, provided that it is reproduced in its entirety, + including this Notice. Modified versions of this Report may also be + copied and distributed for any purpose, provided that the modified + version is clearly presented as such, and that it does not claim to + be a definition of the Haskell 98 Language. + +----------------------------------------------------------------------------- diff --git a/docs/dependencies/licences/haskell/optparse-applicative-0.18.1.0/LICENSE b/docs/dependencies/licences/haskell/optparse-applicative-0.18.1.0/LICENSE new file mode 100644 index 0000000000..0507cc2a81 --- /dev/null +++ b/docs/dependencies/licences/haskell/optparse-applicative-0.18.1.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, Paolo Capriotti + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Paolo Capriotti nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/pem-0.2.4/LICENSE b/docs/dependencies/licences/haskell/pem-0.2.4/LICENSE new file mode 100644 index 0000000000..15c41788af --- /dev/null +++ b/docs/dependencies/licences/haskell/pem-0.2.4/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2018 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/prettyprinter-1.7.1/LICENSE.md b/docs/dependencies/licences/haskell/prettyprinter-1.7.1/LICENSE.md new file mode 100644 index 0000000000..39592102dc --- /dev/null +++ b/docs/dependencies/licences/haskell/prettyprinter-1.7.1/LICENSE.md @@ -0,0 +1,23 @@ +Copyright 2008, Daan Leijen and Max Bolingbroke, 2016 David Luposchainsky. All +rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + - Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +This software is provided by the copyright holders "as is" and any express or +implied warranties, including, but not limited to, the implied warranties of +merchantability and fitness for a particular purpose are disclaimed. In no event +shall the copyright holders be liable for any direct, indirect, incidental, +special, exemplary, or consequential damages (including, but not limited to, +procurement of substitute goods or services; loss of use, data, or profits; or +business interruption) however caused and on any theory of liability, whether in +contract, strict liability, or tort (including negligence or otherwise) arising +in any way out of the use of this software, even if advised of the possibility +of such damage. diff --git a/docs/dependencies/licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md b/docs/dependencies/licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md new file mode 100644 index 0000000000..39592102dc --- /dev/null +++ b/docs/dependencies/licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md @@ -0,0 +1,23 @@ +Copyright 2008, Daan Leijen and Max Bolingbroke, 2016 David Luposchainsky. All +rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + - Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +This software is provided by the copyright holders "as is" and any express or +implied warranties, including, but not limited to, the implied warranties of +merchantability and fitness for a particular purpose are disclaimed. In no event +shall the copyright holders be liable for any direct, indirect, incidental, +special, exemplary, or consequential damages (including, but not limited to, +procurement of substitute goods or services; loss of use, data, or profits; or +business interruption) however caused and on any theory of liability, whether in +contract, strict liability, or tort (including negligence or otherwise) arising +in any way out of the use of this software, even if advised of the possibility +of such damage. diff --git a/docs/dependencies/licences/haskell/primitive-0.9.0.0/LICENSE b/docs/dependencies/licences/haskell/primitive-0.9.0.0/LICENSE new file mode 100644 index 0000000000..fc213a6ffb --- /dev/null +++ b/docs/dependencies/licences/haskell/primitive-0.9.0.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2008-2009, Roman Leshchinskiy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + diff --git a/docs/dependencies/licences/haskell/psqueues-0.2.8.0/LICENSE b/docs/dependencies/licences/haskell/psqueues-0.2.8.0/LICENSE new file mode 100644 index 0000000000..92337b951e --- /dev/null +++ b/docs/dependencies/licences/haskell/psqueues-0.2.8.0/LICENSE @@ -0,0 +1,31 @@ +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/random-1.2.1.1/LICENSE b/docs/dependencies/licences/haskell/random-1.2.1.1/LICENSE new file mode 100644 index 0000000000..06bb641487 --- /dev/null +++ b/docs/dependencies/licences/haskell/random-1.2.1.1/LICENSE @@ -0,0 +1,63 @@ +This library (libraries/base) is derived from code from two +sources: + + * Code from the GHC project which is largely (c) The University of + Glasgow, and distributable under a BSD-style license (see below), + + * Code from the Haskell 98 Report which is (c) Simon Peyton Jones + and freely redistributable (but see the full license for + restrictions). + +The full text of these licenses is reproduced below. Both of the +licenses are BSD-style or compatible. + +----------------------------------------------------------------------------- + +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +----------------------------------------------------------------------------- + +Code derived from the document "Report on the Programming Language +Haskell 98", is distributed under the following license: + + Copyright (c) 2002 Simon Peyton Jones + + The authors intend this Report to belong to the entire Haskell + community, and so we grant permission to copy and distribute it for + any purpose, provided that it is reproduced in its entirety, + including this Notice. Modified versions of this Report may also be + copied and distributed for any purpose, provided that the modified + version is clearly presented as such, and that it does not claim to + be a definition of the Haskell 98 Language. + +----------------------------------------------------------------------------- diff --git a/docs/dependencies/licences/haskell/record-hasfield-1.0/LICENSE b/docs/dependencies/licences/haskell/record-hasfield-1.0/LICENSE new file mode 100644 index 0000000000..f6a9dc60f7 --- /dev/null +++ b/docs/dependencies/licences/haskell/record-hasfield-1.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Adam Gundry and Neil Mitchell 2018-2019. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Neil Mitchell nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/resourcet-1.3.0/LICENSE b/docs/dependencies/licences/haskell/resourcet-1.3.0/LICENSE new file mode 100644 index 0000000000..fe6d4e9b3f --- /dev/null +++ b/docs/dependencies/licences/haskell/resourcet-1.3.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c)2011, Michael Snoyman + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Michael Snoyman nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/safe-exceptions-0.1.7.4/LICENSE b/docs/dependencies/licences/haskell/safe-exceptions-0.1.7.4/LICENSE new file mode 100644 index 0000000000..9a69c0901b --- /dev/null +++ b/docs/dependencies/licences/haskell/safe-exceptions-0.1.7.4/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/scientific-0.3.7.0/LICENSE b/docs/dependencies/licences/haskell/scientific-0.3.7.0/LICENSE new file mode 100644 index 0000000000..c1015843aa --- /dev/null +++ b/docs/dependencies/licences/haskell/scientific-0.3.7.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2013, Bas van Dijk + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Bas van Dijk nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/semialign-1.3/LICENSE b/docs/dependencies/licences/haskell/semialign-1.3/LICENSE new file mode 100644 index 0000000000..9d6d650165 --- /dev/null +++ b/docs/dependencies/licences/haskell/semialign-1.3/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, C. McCann, 2015-2019 Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of C. McCann nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/semigroupoids-6.0.0.1/LICENSE b/docs/dependencies/licences/haskell/semigroupoids-6.0.0.1/LICENSE new file mode 100644 index 0000000000..1812ac961c --- /dev/null +++ b/docs/dependencies/licences/haskell/semigroupoids-6.0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright 2011-2015 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/simple-logger-0.1.1/LICENSE b/docs/dependencies/licences/haskell/simple-logger-0.1.1/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/socks-0.6.1/LICENSE b/docs/dependencies/licences/haskell/socks-0.6.1/LICENSE new file mode 100644 index 0000000000..ed6f548a98 --- /dev/null +++ b/docs/dependencies/licences/haskell/socks-0.6.1/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2019 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/split-0.2.4/LICENSE b/docs/dependencies/licences/haskell/split-0.2.4/LICENSE new file mode 100644 index 0000000000..648167ba61 --- /dev/null +++ b/docs/dependencies/licences/haskell/split-0.2.4/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2008 Brent Yorgey, Louis Wasserman + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of other contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/splitmix-0.1.0.5/LICENSE b/docs/dependencies/licences/haskell/splitmix-0.1.0.5/LICENSE new file mode 100644 index 0000000000..96445e5484 --- /dev/null +++ b/docs/dependencies/licences/haskell/splitmix-0.1.0.5/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/stm-chans-3.0.0.9/LICENSE b/docs/dependencies/licences/haskell/stm-chans-3.0.0.9/LICENSE new file mode 100644 index 0000000000..55157e8352 --- /dev/null +++ b/docs/dependencies/licences/haskell/stm-chans-3.0.0.9/LICENSE @@ -0,0 +1,35 @@ +=== stm-chans license === + +Copyright (c) 2011--2013, wren gayle romano. +ALL RIGHTS RESERVED. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the copyright holders nor the names of + other contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/streaming-commons-0.2.2.6/LICENSE b/docs/dependencies/licences/haskell/streaming-commons-0.2.2.6/LICENSE new file mode 100644 index 0000000000..7198785aaf --- /dev/null +++ b/docs/dependencies/licences/haskell/streaming-commons-0.2.2.6/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/docs/dependencies/licences/haskell/strict-0.5/LICENSE b/docs/dependencies/licences/haskell/strict-0.5/LICENSE new file mode 100644 index 0000000000..4782550806 --- /dev/null +++ b/docs/dependencies/licences/haskell/strict-0.5/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) Roman Leshchinskiy 2006-2007 + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/tagged-0.8.8/LICENSE b/docs/dependencies/licences/haskell/tagged-0.8.8/LICENSE new file mode 100644 index 0000000000..db046152dc --- /dev/null +++ b/docs/dependencies/licences/haskell/tagged-0.8.8/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2009-2015 Edward Kmett +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Edward Kmett nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/temporary-1.3/LICENSE b/docs/dependencies/licences/haskell/temporary-1.3/LICENSE new file mode 100644 index 0000000000..db55f34db3 --- /dev/null +++ b/docs/dependencies/licences/haskell/temporary-1.3/LICENSE @@ -0,0 +1,27 @@ +Copyright + (c) 2003-2006, Isaac Jones + (c) 2005-2009, Duncan Coutts + (c) 2008, Maximilian Bolingbroke + ... and other contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted +provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + * Neither the name of Maximilian Bolingbroke nor the names of other contributors may be used to + endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/terminal-0.2.0.0/LICENSE b/docs/dependencies/licences/haskell/terminal-0.2.0.0/LICENSE new file mode 100644 index 0000000000..d45361ccf6 --- /dev/null +++ b/docs/dependencies/licences/haskell/terminal-0.2.0.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Lars Petersen (c) 2018 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Lars Petersen nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/text-iso8601-0.1/LICENSE b/docs/dependencies/licences/haskell/text-iso8601-0.1/LICENSE new file mode 100644 index 0000000000..fc90c466c7 --- /dev/null +++ b/docs/dependencies/licences/haskell/text-iso8601-0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2023 Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/text-short-0.1.5/LICENSE b/docs/dependencies/licences/haskell/text-short-0.1.5/LICENSE new file mode 100644 index 0000000000..98d4b1ad78 --- /dev/null +++ b/docs/dependencies/licences/haskell/text-short-0.1.5/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Herbert Valerio Riedel nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/th-abstraction-0.6.0.0/LICENSE b/docs/dependencies/licences/haskell/th-abstraction-0.6.0.0/LICENSE new file mode 100644 index 0000000000..70f6077c10 --- /dev/null +++ b/docs/dependencies/licences/haskell/th-abstraction-0.6.0.0/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2017-2020 Eric Mertens + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/docs/dependencies/licences/haskell/th-compat-0.1.4/LICENSE b/docs/dependencies/licences/haskell/th-compat-0.1.4/LICENSE new file mode 100644 index 0000000000..879f215afc --- /dev/null +++ b/docs/dependencies/licences/haskell/th-compat-0.1.4/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2020, Ryan Scott + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Ryan Scott nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/these-1.2/LICENSE b/docs/dependencies/licences/haskell/these-1.2/LICENSE new file mode 100644 index 0000000000..9d6d650165 --- /dev/null +++ b/docs/dependencies/licences/haskell/these-1.2/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, C. McCann, 2015-2019 Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of C. McCann nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/time-compat-1.9.6.1/LICENSE b/docs/dependencies/licences/haskell/time-compat-1.9.6.1/LICENSE new file mode 100644 index 0000000000..910ef51bb4 --- /dev/null +++ b/docs/dependencies/licences/haskell/time-compat-1.9.6.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2019 time contibutors, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/time-manager-0.0.1/LICENSE b/docs/dependencies/licences/haskell/time-manager-0.0.1/LICENSE new file mode 100644 index 0000000000..d9f041796e --- /dev/null +++ b/docs/dependencies/licences/haskell/time-manager-0.0.1/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2012 Michael Snoyman, http://www.yesodweb.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/tls-1.9.0/LICENSE b/docs/dependencies/licences/haskell/tls-1.9.0/LICENSE new file mode 100644 index 0000000000..96ec822eb9 --- /dev/null +++ b/docs/dependencies/licences/haskell/tls-1.9.0/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2015 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/transformers-base-0.4.6/LICENSE b/docs/dependencies/licences/haskell/transformers-base-0.4.6/LICENSE new file mode 100644 index 0000000000..9d51261b52 --- /dev/null +++ b/docs/dependencies/licences/haskell/transformers-base-0.4.6/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2011, Mikhail Vorozhtsov, Bas van Dijk +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +- Neither the names of the copyright owners nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/transformers-compat-0.7.2/LICENSE b/docs/dependencies/licences/haskell/transformers-compat-0.7.2/LICENSE new file mode 100644 index 0000000000..50586d197c --- /dev/null +++ b/docs/dependencies/licences/haskell/transformers-compat-0.7.2/LICENSE @@ -0,0 +1,30 @@ +Copyright 2012-2015 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/type-equality-1/LICENSE b/docs/dependencies/licences/haskell/type-equality-1/LICENSE new file mode 100644 index 0000000000..c30f3e7d31 --- /dev/null +++ b/docs/dependencies/licences/haskell/type-equality-1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2009 Erik Hesselink, 2019 Oleg Grenrus, Ryan Scott + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of authors nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/typed-process-0.2.11.1/LICENSE b/docs/dependencies/licences/haskell/typed-process-0.2.11.1/LICENSE new file mode 100644 index 0000000000..50bb8ea0cf --- /dev/null +++ b/docs/dependencies/licences/haskell/typed-process-0.2.11.1/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 FP Complete, https://www.fpcomplete.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/unix-compat-0.7.1/LICENSE b/docs/dependencies/licences/haskell/unix-compat-0.7.1/LICENSE new file mode 100644 index 0000000000..cc3be3e947 --- /dev/null +++ b/docs/dependencies/licences/haskell/unix-compat-0.7.1/LICENSE @@ -0,0 +1,31 @@ +BSD 3-Clause License + +Copyright (c) 2007-2008, Björn Bringert +Copyright (c) 2007-2009, Duncan Coutts +Copyright (c) 2010-2011, Jacob Stanley +Copyright (c) 2011, Bryan O'Sullivan +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +- Neither the names of the copyright owners nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/unix-time-0.4.11/LICENSE b/docs/dependencies/licences/haskell/unix-time-0.4.11/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/unix-time-0.4.11/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/unliftio-0.2.25.0/LICENSE b/docs/dependencies/licences/haskell/unliftio-0.2.25.0/LICENSE new file mode 100644 index 0000000000..1349134e01 --- /dev/null +++ b/docs/dependencies/licences/haskell/unliftio-0.2.25.0/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2017 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/unliftio-core-0.2.1.0/LICENSE b/docs/dependencies/licences/haskell/unliftio-core-0.2.1.0/LICENSE new file mode 100644 index 0000000000..1349134e01 --- /dev/null +++ b/docs/dependencies/licences/haskell/unliftio-core-0.2.1.0/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2017 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/unordered-containers-0.2.19.1/LICENSE b/docs/dependencies/licences/haskell/unordered-containers-0.2.19.1/LICENSE new file mode 100644 index 0000000000..5eb7e1bdda --- /dev/null +++ b/docs/dependencies/licences/haskell/unordered-containers-0.2.19.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2010, Johan Tibell + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Johan Tibell nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/uuid-1.3.15/LICENSE b/docs/dependencies/licences/haskell/uuid-1.3.15/LICENSE new file mode 100644 index 0000000000..dc46bca24c --- /dev/null +++ b/docs/dependencies/licences/haskell/uuid-1.3.15/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2008, Antoine Latter + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + * The names of the authors may not be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/uuid-types-1.0.5.1/LICENSE b/docs/dependencies/licences/haskell/uuid-types-1.0.5.1/LICENSE new file mode 100644 index 0000000000..dc46bca24c --- /dev/null +++ b/docs/dependencies/licences/haskell/uuid-types-1.0.5.1/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2008, Antoine Latter + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + * The names of the authors may not be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/vector-0.13.1.0/LICENSE b/docs/dependencies/licences/haskell/vector-0.13.1.0/LICENSE new file mode 100644 index 0000000000..ef2a5268d6 --- /dev/null +++ b/docs/dependencies/licences/haskell/vector-0.13.1.0/LICENSE @@ -0,0 +1,32 @@ +Copyright (c) 2008-2012, Roman Leshchinskiy + 2020-2022, Alexey Kuleshevich + 2020-2022, Aleksey Khudyakov + 2020-2022, Andrew Lelechenko +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/vector-algorithms-0.9.0.1/LICENSE b/docs/dependencies/licences/haskell/vector-algorithms-0.9.0.1/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/vector-stream-0.1.0.0/LICENSE b/docs/dependencies/licences/haskell/vector-stream-0.1.0.0/LICENSE new file mode 100644 index 0000000000..ef2a5268d6 --- /dev/null +++ b/docs/dependencies/licences/haskell/vector-stream-0.1.0.0/LICENSE @@ -0,0 +1,32 @@ +Copyright (c) 2008-2012, Roman Leshchinskiy + 2020-2022, Alexey Kuleshevich + 2020-2022, Aleksey Khudyakov + 2020-2022, Andrew Lelechenko +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/websockets-0.12.7.3/LICENCE b/docs/dependencies/licences/haskell/websockets-0.12.7.3/LICENCE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/witherable-0.4.2/LICENSE b/docs/dependencies/licences/haskell/witherable-0.4.2/LICENSE new file mode 100644 index 0000000000..1994a4c649 --- /dev/null +++ b/docs/dependencies/licences/haskell/witherable-0.4.2/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2014, Fumiaki Kinoshita + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Fumiaki Kinoshita nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/yaml-0.11.11.2/LICENSE b/docs/dependencies/licences/haskell/yaml-0.11.11.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/zip-2.0.0/LICENSE.md b/docs/dependencies/licences/haskell/zip-2.0.0/LICENSE.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/zlib-0.6.3.0/LICENSE b/docs/dependencies/licences/haskell/zlib-0.6.3.0/LICENSE new file mode 100644 index 0000000000..e4d25e0e0a --- /dev/null +++ b/docs/dependencies/licences/haskell/zlib-0.6.3.0/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2006-2016, Duncan Coutts +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. This clause is intentionally left blank. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/zstd-0.1.3.0/LICENSE b/docs/dependencies/licences/haskell/zstd-0.1.3.0/LICENSE new file mode 100644 index 0000000000..a793a80289 --- /dev/null +++ b/docs/dependencies/licences/haskell/zstd-0.1.3.0/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For Zstandard software + +Copyright (c) 2016-present, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/rfcs/2025-01-20-member-mentions.md b/docs/rfcs/2025-01-20-member-mentions.md new file mode 100644 index 0000000000..fdb35c608b --- /dev/null +++ b/docs/rfcs/2025-01-20-member-mentions.md @@ -0,0 +1,60 @@ +# Member mentions + +## Problem + +Mention members in the messages. + +There are several UX objectives that mentions must deliver: +- to notify the user about the messages that mention the user or to reply to user's messages - groups must have this notification mode (already present in the API), +- to allow the user to navigate to unread mentions and replies, +- to highlight mentions and allow all users to open mentioned member profile - this is the least important objective. + +## Solution + +Message text should include the reference to the shared member display name: + +``` +Hello @name +``` + +or + +``` +Hello @'member name' +``` + +This is the same format that people already use and that is currently supported in the API. The name in the message should use the display name at the time of mention, both for backwards compatibility and for better view in compose field, and the message should additionally include the mapping from used display names to shared group member IDs, and the UI would show the current display name (at the time of loading the message to the view). + +For this mapping the message JSON will include the array of mentions, as objects with properties `displayName` and `memberId`. This is to ensure the intent and that the fragments of text are treated as mentions. + +Using an immutable `memberId` would prevent any race conditions and duplicate display names. The receiving client would show a local view name (display name or an alias), and might open a correct member card when mention is tapped. + +As display names are not unique in the group, we should convert them to locally-unique names (per message), by appending _1, _2, as necessary, and the same locally unique names should be used in the mapping to member IDs. These locally unique names must NOT use local user aliases, and must NOT use localDisplayName, as otherwise it may leak information that is known only to the user's client. + +There should be a reasonable limit on the number of mentions per message, e.g. 3. This is to prevent abuse, expensive processing both in the client and in super-peers that would have to forward member profiles if they were not forwarded before. This limit has to be enforced both on sending and receiving ends. + +## UX for sending mentions + +When a member types '@' character in the entry field, the app would show the paginated list of most recently active members, with search. This requires a separate API, and the same API can be used to show a paginated member list - loading the full list is already quite expensive with groups over 1-2k members. + +## UX for navigating to mentions + +The current circles with unread messages should indicate the number of unread mentions (including replies) above and below the view. Tapping the circle should navigate to the next unread mention, and not to the bottom/top of the conversation. Long-pressing the circle should offer the option to navigate to the top/bottom. In the absense of mentions, tapping circles would navigate to top/bottom. + +## Message UI + +Item text will include markdown elements for mentioned members. This will be used when rendering to show member display names or local aliases. + +Chat items data will include the list of members used in the chat item, including view names and member IDs. + +## Forwarding and saving to local items + +When forwarding to another conversation or saving to notes a message with mentions the app should use: +- current display names instead of display names used in the message. +- remove mentions mapping from the message data. + +## Schema + +Two new columns for chat_items table: +- user_mention - 0 or 1 to indicate whether a message is a reply to user's message or mentions user. +- member_mentions - the object mapping display names to member IDs, either as JSON, or in a more economical comma-separated list of "ID:name" strings (or "ID:'member name'). This field can be processed to load mention information, with the limit of 3 mentions per message it's sufficient. diff --git a/docs/rfcs/2025-02-17-member-send-limits.md b/docs/rfcs/2025-02-17-member-send-limits.md new file mode 100644 index 0000000000..cc98d63e46 --- /dev/null +++ b/docs/rfcs/2025-02-17-member-send-limits.md @@ -0,0 +1,97 @@ +# Member sending limits + +## Problem + +Rate limiting member sending to prevent abuse in groups. + +## Solution + +Each member record to have `rateLimit :: Maybe MemberRateLimit`, Nothing by default. Nothing means default rate limit for regular members (`MRLWindow`), and no limit for owners/admins/moderators (`MRLNone`). Default rate limit is defined in configuration, e.g. limit of 15 messages in 60 second window, or could be defined in group profile. + +Rate limit can be overridden per member by sending `XGrpMemRestrict` with updated `rateLimit` by member of same role or higher, similar to changing roles. `APIRateLimitMember` allows to enable or disable rate limiting for member, we could also provide more granular control in it (pass `WindowLimit`), but it seems unnecessary complex for UI. + +```haskell +data MemberRateLimit + = MRLNone -- default for owners, admins, moderators + | MRLWindow WindowLimit + deriving (Eq, Show) + +data WindowLimit = WindowLimit + { window :: Int, -- seconds + limit :: Int + } + deriving (Eq, Show) + +-- sent in XGrpMemRestrict +data MemberRestrictions = MemberRestrictions + { restriction :: MemberRestrictionStatus, + rateLimit :: Maybe MemberRateLimit -- Nothing means use default + } + deriving (Eq, Show) + +-- new api in ChatCommand +| APIRateLimitMember GroupId GroupMemberId + +-- new response in ChatResponse +| CRMemberRateLimit {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + +-- new field in GroupMember +data GroupMember = GroupMember { + ... + rateLimit :: Maybe MemberRateLimit, + ... +} +``` + +Rate limit overrides to be persisted on group member records. + +```sql +ALTER TABLE group_members ADD COLUMN rate_limit TEXT; -- MemberRateLimit JSON encoded +``` + +Limits can be tracked inside fixed windows both for receiving and sending. + +```haskell +data ChatController = ChatController { + ... + memberLimits :: TMap (GroupId, MemberId) (TVar MemberRateLimitWindow), + ownLimits :: TMap GroupId (TVar MemberRateLimitWindow), + ... +} + +data MemberRateLimitWindow = MemberRateLimitWindow { + startedAt :: UTCTime, + windowLimit :: WindowLimit, + messages :: Int +} +``` + +Client to track limit for each writing member in state - `memberLimits`. If current window's interval has passed, checked against `startedAt` of `MemberRateLimitWindow`, reset `messages` counter. + +Track own limits per group - `ownLimits`. When limit in group is reached, `CRGroupSendingLimited blocked = True` event is sent to UI to block sending in group. Unblock group sending in UI by scheduling background process to send `CRGroupSendingLimited blocked = False` after interval? + +```haskell +-- new event in ChatResponse +| CRGroupSendingLimited {user :: User, groupInfo :: GroupInfo, blocked :: Bool} +``` + +### Receiving messages from limited members + +Receiving message from member that exceeded their limit would fail it as prohibited. We can limit content messages, updates, reactions, etc. Practically only regular members would be limited, so there's no need for limiting service messages. Should we limit deletes? + +Problems: + +- Inconsistencies in window tracking on sending and receiving sides -> track based on item_ts?; +- Subscription produces message surges; +- Server downtime (or network outage) leads to accumulation of scheduled messages on sending side -> item_ts tracking wouldn't help. Issue is similar to subscription, where many messages can be received in short span legitimately. +- This approach doesn't reduce load of retrieving message and of all machinery passing from agent to chat. + +Subscription issue could be solved by not tracking limits during subscription (client "knows" when it has subscribed) and for some time after it. For how long - 30 seconds / 1 minute? - arbitrary, longer absence periods lead to more not yet retrieved messages, freed quotas resulting in more sent pending messages. + +Better solution would be to not drop (prohibit) messages at all, but stop reception per connection for periods of time. Possible approaches: + +- Don't send (delay) ACK - bad idea as it would lead to repeated processing on client restart, and other possible failures in delivery. +- ACK with parameter `wait` for server - server would wait before sending next message. +- Unsubscribe (kill client?)/resubscribe after some time - more expensive. +- Signal agent to hold on next message - similar to delayed ACK but at least we don't process message. (for example, also via ACK but parameter to wait is for agent) +- Rework chat-to-agent communication to communicate via per connection queue (currently it's single `subQ`) - complex - but we get less expensive "unsubscribe"? Essentially agent still holds on to message like in previous approach. diff --git a/docs/rfcs/2025-03-07-group-knocking.md b/docs/rfcs/2025-03-07-group-knocking.md new file mode 100644 index 0000000000..fedbbfaf72 --- /dev/null +++ b/docs/rfcs/2025-03-07-group-knocking.md @@ -0,0 +1,38 @@ +# Group knocking + +## Problem + +In v6.3 release we added an option to "manually" approve members before introducing to group, based on decision made via `acceptMember` chat hook. Currently it's not supported in UI clients, and only used for directory service bot captcha challenge. + +The goal of next improvement is to let: +- admins screen members before admitting to group, while not limiting communication with joining member to a single admin (and also removing the need for admin to be a highly available host of the group link); +- and group owners set up other arbitrary automated challenges or entry rules, while still being able to advertise groups in directory service. + +## Solution + +Group link host (further host), knowing group requires admin approval, would initially only introduce member pending approval to admins. Admins can connect with member for screening, meanwhile host would be forwarding messages as usual between connecting members. As a result of screening, pending member can either be removed, or approved by admins. + +Upon acceptance, for further member connections to not depend on availability of admins, host should not only forward acceptance message, but also introduce remaining members to now accepted joining member. Respectively, admins' clients should not introduce members for approved members who are not their invitees. + +For group owners to be able to set up alternative automated challenges, these are some possible alternatives: +- We could add a new role `Approver`, so that instead of adding all \[possibly human\] admins, host would initially introduce only approvers. +- It could be an orthogonal to role member setting (would require protocol extension). +- List of "approver" member IDs could be communicated to host client. + +### Implementation details draft + +Host needs to have knowledge whether to automatically accept, or only introduce admins/approvers. + +```sql +ALTER TABLE group_profiles ADD COLUMN approval TEXT; -- comma separated member IDs; null - automatic introduction + +-- or + +ALTER TABLE group_profiles ADD COLUMN approval INTEGER; -- if based on `Approver` role +``` + +Alternatively, a different extension of protocol could be done in order to communicate group approval rule from owner to host outside of group profile (special messages). + +Admins/approvers need to have separate conversation per pending member, requires adding scope to chat items. + +Host to have specific processing of forwarded `XGrpLinkAcpt` - continue introduction of remaining members. diff --git a/flake.nix b/flake.nix index 1a1043c5f2..9ee0c52020 100644 --- a/flake.nix +++ b/flake.nix @@ -198,6 +198,7 @@ packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ pkgs.pkgsCross.mingwW64.openssl ]; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ pkgs.pkgsCross.mingwW64.openssl @@ -336,6 +337,7 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) @@ -445,6 +447,7 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (androidPkgs.openssl.override { static = true; }) @@ -550,6 +553,7 @@ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ # TODO: have a cross override for iOS, that sets this. @@ -565,6 +569,7 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ ((pkgs.openssl.override { static = true; }).overrideDerivation (old: { CFLAGS = "-mcpu=apple-a7 -march=armv8-a+norcpc" ;})) @@ -583,6 +588,7 @@ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (pkgs.openssl.override { static = true; }) @@ -597,6 +603,7 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (pkgs.openssl.override { static = true; }) diff --git a/media-logos/README.md b/media-logos/README.md new file mode 100644 index 0000000000..3780029331 --- /dev/null +++ b/media-logos/README.md @@ -0,0 +1,5 @@ +# SimpleX logos + +Please use these logos in your publications about SimpleX Chat. + +You can also use app screenshots and diagrams from our [blog posts](../blog/README.md) – please reference them. diff --git a/media-logos/simplex-app-icon-dark.png b/media-logos/simplex-app-icon-dark.png new file mode 100644 index 0000000000..aba97be8ae Binary files /dev/null and b/media-logos/simplex-app-icon-dark.png differ diff --git a/media-logos/simplex-app-icon-light.png b/media-logos/simplex-app-icon-light.png new file mode 100644 index 0000000000..9a44d45b1f Binary files /dev/null and b/media-logos/simplex-app-icon-light.png differ diff --git a/media-logos/simplex-logo-dark.png b/media-logos/simplex-logo-dark.png new file mode 100644 index 0000000000..98c1f500e9 Binary files /dev/null and b/media-logos/simplex-logo-dark.png differ diff --git a/media-logos/simplex-logo-light.png b/media-logos/simplex-logo-light.png new file mode 100644 index 0000000000..6edecb0dd9 Binary files /dev/null and b/media-logos/simplex-logo-light.png differ diff --git a/media-logos/simplex-symbol-dark.png b/media-logos/simplex-symbol-dark.png new file mode 100644 index 0000000000..c561422bdb Binary files /dev/null and b/media-logos/simplex-symbol-dark.png differ diff --git a/media-logos/simplex-symbol-dark.svg b/media-logos/simplex-symbol-dark.svg new file mode 100644 index 0000000000..fa598acf3d --- /dev/null +++ b/media-logos/simplex-symbol-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media-logos/simplex-symbol-light.png b/media-logos/simplex-symbol-light.png new file mode 100644 index 0000000000..865f37bfc8 Binary files /dev/null and b/media-logos/simplex-symbol-light.png differ diff --git a/media-logos/simplex-symbol-light.svg b/media-logos/simplex-symbol-light.svg new file mode 100644 index 0000000000..d8b5951a0b --- /dev/null +++ b/media-logos/simplex-symbol-light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index e512e06672..135dc41ba7 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -29,7 +29,6 @@ export type ChatCommand = | APIRejectContact | APIUpdateProfile | APISetContactAlias - | APIParseMarkdown | NewGroup | APIAddMember | APIJoinGroup @@ -128,7 +127,6 @@ type ChatCommandTag = | "apiRejectContact" | "apiUpdateProfile" | "apiSetContactAlias" - | "apiParseMarkdown" | "newGroup" | "apiAddMember" | "apiJoinGroup" @@ -355,11 +353,6 @@ export interface APISetContactAlias extends IChatCommand { localAlias: string } -export interface APIParseMarkdown extends IChatCommand { - type: "apiParseMarkdown" - text: string -} - export interface NewGroup extends IChatCommand { type: "newGroup" groupProfile: GroupProfile @@ -732,8 +725,6 @@ export function cmdString(cmd: ChatCommand): string { return `/_profile ${cmd.userId} ${JSON.stringify(cmd.profile)}` case "apiSetContactAlias": return `/_set alias @${cmd.contactId} ${cmd.localAlias.trim()}` - case "apiParseMarkdown": - return `/_parse ${cmd.text}` case "newGroup": return `/_group ${JSON.stringify(cmd.groupProfile)}` case "apiAddMember": diff --git a/packages/simplex-chat-client/typescript/src/response.ts b/packages/simplex-chat-client/typescript/src/response.ts index 2e92e335df..5f91baa7db 100644 --- a/packages/simplex-chat-client/typescript/src/response.ts +++ b/packages/simplex-chat-client/typescript/src/response.ts @@ -84,7 +84,6 @@ export type ChatResponse = | CRGroupRemoved | CRGroupDeleted | CRGroupUpdated - | CRUserContactLinkSubscribed | CRUserContactLinkSubError | CRContactConnectionDeleted | CRMessageError @@ -182,7 +181,6 @@ type ChatResponseTag = | "groupRemoved" | "groupDeleted" | "groupUpdated" - | "userContactLinkSubscribed" | "userContactLinkSubError" | "newContactConnection" | "contactConnectionDeleted" @@ -721,10 +719,6 @@ export interface CRGroupUpdated extends CR { member_?: GroupMember } -export interface CRUserContactLinkSubscribed extends CR { - type: "userContactLinkSubscribed" -} - export interface CRUserContactLinkSubError extends CR { type: "userContactLinkSubError" chatError: ChatError 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/db/README.md b/scripts/db/README.md new file mode 100644 index 0000000000..68d4f74ac7 --- /dev/null +++ b/scripts/db/README.md @@ -0,0 +1,105 @@ +# Transfer data from SQLite to Postgres database + +1. \* Decrypt SQLite database if it is encrypted. + + ```sh + sqlcipher encrypted_simplex_v1_agent.db + ``` + + ```sql + PRAGMA key = 'password'; + ATTACH DATABASE 'simplex_v1_agent.db' AS plaintext KEY ''; + SELECT sqlcipher_export('plaintext'); + DETACH DATABASE plaintext; + ``` + + Repeat for `simplex_v1_chat.db`. + +2. Prepare Postgres database. + + - Create Postgres database. In shell: + + ```sh + createdb -O simplex simplex_v1 + ``` + + Or via query. + + - Build `simplex-chat` executable with `client_postgres` flag and run it to initialize new chat database. + + This should create `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas in `simplex_v1` database, with `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. + +3. Load data from decrypted SQLite databases to Postgres database via pgloader. + + Install pgloader and add it to PATH. Run in shell (substitute paths): + + ```sh + SQLITE_DBPATH='simplex_v1_agent.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_agent_schema' pgloader --on-error-stop sqlite.load + + SQLITE_DBPATH='simplex_v1_chat.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_chat_schema' pgloader --on-error-stop sqlite.load + ``` + +4. Update sequences for Postgres tables. + + ```sql + DO $$ + DECLARE + rec RECORD; + BEGIN + EXECUTE 'SET SEARCH_PATH TO simplex_v1_agent_schema'; + + FOR rec IN + SELECT + table_name, + column_name, + pg_get_serial_sequence(table_name, column_name) AS seq_name + FROM + information_schema.columns + WHERE + table_schema = 'simplex_v1_agent_schema' + AND identity_generation = 'ALWAYS' + LOOP + EXECUTE format( + 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', + rec.seq_name, rec.column_name, rec.table_name + ); + END LOOP; + END $$; + ``` + + Repeat for `simplex_v1_chat_schema`. + +5. \* Compare number of rows between Postgres and SQLite tables. + + To check number of rows for all tables in Postgres database schema run: + + ```sql + WITH tbl AS ( + SELECT table_schema, table_name + FROM information_schema.Tables + WHERE table_name NOT LIKE 'pg_%' + AND table_schema IN ('simplex_v1_agent_schema') + ) + SELECT + table_schema AS schema_name, + table_name, + (xpath('/row/c/text()', query_to_xml( + format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' + )))[1]::text::int AS records_count + FROM tbl + ORDER BY records_count DESC; + ``` + + Repeat for `simplex_v1_chat_schema`. + +6. Build and run desktop app with Postgres backend. + + Run in shell (paths are from project root): + + ```sh + ./scripts/desktop/build-lib-mac.sh arm64 postgres + + ./gradlew runDistributable -Pdatabase.backend=postgres + # or + ./gradlew packageDmg -Pdatabase.backend=postgres + ``` diff --git a/scripts/db/sqlite.load b/scripts/db/sqlite.load new file mode 100644 index 0000000000..105e8c39d2 --- /dev/null +++ b/scripts/db/sqlite.load @@ -0,0 +1,20 @@ +LOAD DATABASE + FROM {{SQLITE_DBPATH}} + INTO {{POSTGRES_CONN}} + + WITH include no drop, + truncate, + disable triggers, + create no tables, + create no indexes, + -- pgloader implementation doesn't find "GENERATED ALWAYS AS IDENTITY" sequences, + -- instead we reset sequences manually via custom query after load + reset no sequences, + data only + + EXCLUDING TABLE NAMES LIKE 'migrations', 'sqlite_sequence' + + SET work_mem to '16MB', + maintenance_work_mem to '512 MB', + search_path to '{{POSTGRES_SCHEMA}}' +; diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index bdd46afcd2..fa2ed52b36 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -25,7 +25,7 @@ for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done #rm -rf $BUILD_DIR -cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library' +cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' cd $BUILD_DIR/build mv libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so libsimplex.so 2> /dev/null || true #patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libsimplex.so diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 282f4c11af..b5960cfa2d 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -6,6 +6,7 @@ OS=mac ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" COMPOSE_ARCH=$ARCH GHC_VERSION=9.6.3 +DATABASE_BACKEND="${2:-sqlite}" if [ "$ARCH" == "arm64" ]; then ARCH=aarch64 @@ -24,7 +25,14 @@ for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done rm -rf $BUILD_DIR -cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-install_name,@rpath/$LIB -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' + +if [[ "$DATABASE_BACKEND" == "postgres" ]]; then + echo "Building with postgres backend..." + cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-install_name,@rpath/$LIB -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' +else + echo "Building with sqlite backend..." + cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-install_name,@rpath/$LIB -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' +fi cd $BUILD_DIR/build mv libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT libsimplex.dylib 2> /dev/null || true @@ -100,8 +108,8 @@ cp $BUILD_DIR/build/$LIB apps/multiplatform/common/src/commonMain/cpp/desktop/li cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -LIBCRYPTO_PATH=$(otool -l libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSdrct-*.$LIB_EXT +LIBCRYPTO_PATH=$(otool -l libHSsmplxmq-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsmplxmq-*.$LIB_EXT cp $LIBCRYPTO_PATH libcrypto.3.0.$LIB_EXT chmod 755 libcrypto.3.0.$LIB_EXT install_name_tool -id "libcrypto.3.0.$LIB_EXT" libcrypto.3.0.$LIB_EXT @@ -112,14 +120,18 @@ if [ -n "$LIBCRYPTO_PATH" ]; then install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT $LIB fi -LIBCRYPTO_PATH=$(otool -l libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -if [ -n "$LIBCRYPTO_PATH" ]; then - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsmplxmq*.$LIB_EXT -fi +# We could change libpq and libHSpstgrsql for postgres (?), remove sqlite condition for exit below. +# Unnecessary for now as app with postgres backend is not for distribution. +if [[ "$DATABASE_BACKEND" == "sqlite" ]]; then + LIBCRYPTO_PATH=$(otool -l libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) + if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSdrct-*.$LIB_EXT + fi -LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -if [ -n "$LIBCRYPTO_PATH" ]; then - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsqlcphr-*.$LIB_EXT + LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) + if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsqlcphr-*.$LIB_EXT + fi fi for lib in $(find . -type f -name "*.$LIB_EXT"); do @@ -133,7 +145,9 @@ LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | if [ -n "$LOCAL_DIRS" ]; then echo These libs still point to local directories: echo $LOCAL_DIRS - exit 1 + if [[ "$DATABASE_BACKEND" == "sqlite" ]]; then + exit 1 + fi fi cd - diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index cbb886ccb3..af408d4054 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -51,7 +51,7 @@ echo " ghc-options: -shared -threaded -optl-L$openssl_windows_style_path -opt # Very important! Without it the build fails on linking step since the linker can't find exported symbols. # It looks like GHC bug because with such random path the build ends successfully sed -i "s/ld.lld.exe/abracadabra.exe/" `ghc --print-libdir`/settings -cabal build lib:simplex-chat --constraint 'simplexmq +client_library' +cabal build lib:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake diff --git a/scripts/desktop/prepare-vlc-linux.sh b/scripts/desktop/prepare-vlc-linux.sh index dae1c9255d..a798fd6fc9 100755 --- a/scripts/desktop/prepare-vlc-linux.sh +++ b/scripts/desktop/prepare-vlc-linux.sh @@ -10,11 +10,13 @@ vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linu mkdir $vlc_dir || exit 0 +vlc_tag='v3.0.21-1' +vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-linux-x86_64.appimage" cd /tmp mkdir tmp 2>/dev/null || true cd tmp -curl --tlsv1.2 https://github.com/cmatomic/VLCplayer-AppImage/releases/download/3.0.11.1/VLC_media_player-3.0.11.1-x86_64.AppImage -L -o appimage +curl --tlsv1.2 "${vlc_url}" -L -o appimage chmod +x appimage ./appimage --appimage-extract cp -r squashfs-root/usr/lib/* $vlc_dir diff --git a/scripts/desktop/prepare-vlc-mac.sh b/scripts/desktop/prepare-vlc-mac.sh index 288b5a1a7d..4db2983f67 100755 --- a/scripts/desktop/prepare-vlc-mac.sh +++ b/scripts/desktop/prepare-vlc-mac.sh @@ -9,7 +9,9 @@ if [ "$ARCH" == "arm64" ]; then else vlc_arch=intel64 fi -vlc_version=3.0.19 + +vlc_tag='v3.0.21-1' +vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-macos-${ARCH}.zip" function readlink() { echo "$(cd "$(dirname "$1")"; pwd -P)" @@ -23,7 +25,7 @@ mkdir -p $vlc_dir/vlc || exit 0 cd /tmp mkdir tmp 2>/dev/null || true cd tmp -curl --tlsv1.2 https://github.com/simplex-chat/vlc/releases/download/v$vlc_version/vlc-macos-$ARCH.zip -L -o vlc +curl --tlsv1.2 "${vlc_url}" -L -o vlc unzip -oqq vlc install_name_tool -add_rpath "@loader_path/VLC.app/Contents/MacOS/lib" vlc-cache-gen cd VLC.app/Contents/MacOS/lib diff --git a/scripts/desktop/prepare-vlc-windows.sh b/scripts/desktop/prepare-vlc-windows.sh index 7d2a71a952..4e65528ca0 100644 --- a/scripts/desktop/prepare-vlc-windows.sh +++ b/scripts/desktop/prepare-vlc-windows.sh @@ -10,10 +10,13 @@ vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/wind rm -rf $vlc_dir mkdir -p $vlc_dir/vlc || exit 0 +vlc_tag='v3.0.21-1' +vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-win-x86_64.zip" + cd /tmp mkdir tmp 2>/dev/null || true cd tmp -curl --tlsv1.2 https://irltoolkit.mm.fcix.net/videolan-ftp/vlc/3.0.18/win64/vlc-3.0.18-win64.zip -L -o vlc +curl --tlsv1.2 "${vlc_url}" -L -o vlc $WINDIR\\System32\\tar.exe -xf vlc cd vlc-* # Setting the same date as the date that will be on the file after extraction from JAR to make VLC cache checker happy diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 8141046d8a..b15e382207 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,135 @@ + + https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +

New in v6.3.1-4:

+
    +
  • fixes mentions with trailing punctuation (e.g., hello @name!).
  • +
  • recognizes domain names as links (e.g., simplex.chat).
  • +
  • forward compatibility with "knocking" (a feature for group admins to review and to chat with the new members prior to admitting them to groups, it will be released in 6.4)
  • +
  • support for connecting via short connection links.
  • +
  • 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 + +

New in v6.3.1-3:

+
    +
  • support for connecting via short connection links.
  • +
  • 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 + +

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 + +

New in v6.3.1:

+
    +
  • 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 + +

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/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-5:

+
    +
  • change media filenames when forwarding.
  • +
  • fully delete wallpapers when deleting user or chat.
  • +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 95b8215de8..fa637db5bc 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."23189753751dc52046865ce2d992335495020e91" = "0f1c0bfjqwycsb2nkphhbdiv77zx6q47jdigk7bjal1c4rfla8gy"; + "https://github.com/simplex-chat/simplexmq.git"."56ea2fdd56af5f5a5da41642486aa086d7371823" = "1ninimiccsk0ba4wls2i9rqxj6g057m2k30zw19jvzma4xbalzg8"; "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/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh new file mode 100755 index 0000000000..408d1d4a53 --- /dev/null +++ b/scripts/simplex-chat-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-$repo_name/from-source" "$init_dir/$TAG-$repo_name/prebuilt" + +git -C "$tempdir" clone "$repo.git" &&\ + cd "$tempdir/${repo_name}" &&\ + git checkout "$TAG" + +for os in 22.04 24.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-$repo_name/from-source/simplex-chat-ubuntu-${os_url}-x86-64" + + # Download prebuilt postgresql binary + curl -L \ + --output-dir "$init_dir/$TAG-$repo_name/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-$repo_name" + +# 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' -e "s|-$repo_name||g" > "$path_bin/_sha256sums" + + printf 'Checksums computed - %s\n' "$path_bin/_sha256sums" +fi diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6b0b8bdd82..d35e76f344 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.0.1 +version: 6.4.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -24,6 +24,11 @@ flag swift manual: True default: False +flag client_library + description: Don't build server- and CLI-related code. + manual: True + default: False + flag client_postgres description: Build with PostgreSQL instead of SQLite. manual: True @@ -33,13 +38,9 @@ library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings - Simplex.Chat.Bot - Simplex.Chat.Bot.KnownContacts Simplex.Chat.Call Simplex.Chat.Controller - Simplex.Chat.Core Simplex.Chat.Files - Simplex.Chat.Help Simplex.Chat.Library.Commands Simplex.Chat.Library.Internal Simplex.Chat.Library.Subscriber @@ -54,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 @@ -78,23 +80,32 @@ library Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared Simplex.Chat.Styled - Simplex.Chat.Terminal - Simplex.Chat.Terminal.Input - Simplex.Chat.Terminal.Main - Simplex.Chat.Terminal.Notification - Simplex.Chat.Terminal.Output Simplex.Chat.Types Simplex.Chat.Types.Preferences Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Types.Util Simplex.Chat.Util - Simplex.Chat.View + if !flag(client_library) + exposed-modules: + Simplex.Chat.Bot + Simplex.Chat.Bot.KnownContacts + Simplex.Chat.Core + Simplex.Chat.Help + Simplex.Chat.Terminal + Simplex.Chat.Terminal.Input + Simplex.Chat.Terminal.Main + Simplex.Chat.Terminal.Notification + Simplex.Chat.Terminal.Output + Simplex.Chat.View if flag(client_postgres) exposed-modules: Simplex.Chat.Options.Postgres Simplex.Chat.Store.Postgres.Migrations Simplex.Chat.Store.Postgres.Migrations.M20241220_initial + Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links + Simplex.Chat.Store.Postgres.Migrations.M20250512_member_admission + Simplex.Chat.Store.Postgres.Migrations.M20250513_group_scope else exposed-modules: Simplex.Chat.Archive @@ -220,6 +231,13 @@ library Simplex.Chat.Store.SQLite.Migrations.M20241230_reports Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl + Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history + 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.M20250402_short_links + Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission + Simplex.Chat.Store.SQLite.Migrations.M20250513_group_scope other-modules: Paths_simplex_chat hs-source-dirs: @@ -238,6 +256,7 @@ library , constraints >=0.12 && <0.14 , containers ==0.6.* , crypton ==0.34.* + , crypton-x509 ==1.7.* , data-default ==0.7.* , directory ==1.3.* , email-validate ==2.3.* @@ -253,7 +272,6 @@ library , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* - , scientific ==0.3.7.* , simple-logger ==0.1.* , simplexmq >=6.3 , socks ==0.6.* @@ -292,6 +310,8 @@ library , text >=1.2.4.0 && <1.3 executable simplex-bot + if flag(client_library) + buildable: False main-is: Main.hs other-modules: Paths_simplex_chat @@ -309,6 +329,8 @@ executable simplex-bot cpp-options: -DdbPostgres executable simplex-bot-advanced + if flag(client_library) + buildable: False main-is: Main.hs other-modules: Paths_simplex_chat @@ -335,6 +357,8 @@ executable simplex-bot-advanced text >=1.2.4.0 && <1.3 executable simplex-broadcast-bot + if flag(client_library) + buildable: False main-is: Main.hs hs-source-dirs: apps/simplex-broadcast-bot @@ -365,6 +389,8 @@ executable simplex-broadcast-bot text >=1.2.4.0 && <1.3 executable simplex-chat + if flag(client_library) + buildable: False main-is: Main.hs other-modules: Server @@ -396,6 +422,8 @@ executable simplex-chat text >=1.2.4.0 && <1.3 executable simplex-directory-service + if flag(client_library) + buildable: False main-is: Main.hs hs-source-dirs: apps/simplex-directory-service @@ -403,6 +431,8 @@ executable simplex-directory-service default-extensions: StrictData other-modules: + Directory.BlockedWords + Directory.Captcha Directory.Events Directory.Options Directory.Search @@ -411,18 +441,23 @@ executable simplex-directory-service Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - async ==2.2.* + aeson ==2.2.* + , async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 , composition ==1.0.* , containers ==0.6.* , directory ==1.3.* + , mtl >=2.3.1 && <3.0 , optparse-applicative >=0.15 && <0.17 + , process >=1.6 && <1.6.18 + , random >=1.1 && <1.3 , simple-logger ==0.1.* , simplex-chat , simplexmq >=6.3 , stm ==2.5.* , time ==1.12.* + , unicode-transforms ==0.4.* default-language: Haskell2010 if flag(client_postgres) cpp-options: -DdbPostgres @@ -436,6 +471,10 @@ executable simplex-directory-service , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test + if flag(swift) + cpp-options: -DswiftJSON + if flag(client_library) + buildable: False type: exitcode-stdio-1.0 main-is: Test.hs other-modules: @@ -445,6 +484,7 @@ test-suite simplex-chat-test ChatTests ChatTests.ChatList ChatTests.Direct + ChatTests.DBUtils ChatTests.Files ChatTests.Forward ChatTests.Groups @@ -463,14 +503,20 @@ test-suite simplex-chat-test ViewTests Broadcast.Bot Broadcast.Options + Directory.BlockedWords + Directory.Captcha Directory.Events Directory.Options Directory.Search Directory.Service Directory.Store Paths_simplex_chat - if !flag(client_postgres) + if flag(client_postgres) other-modules: + ChatTests.DBUtils.Postgres + else + other-modules: + ChatTests.DBUtils.SQLite MobileTests SchemaDump WebRTCTests @@ -501,6 +547,7 @@ test-suite simplex-chat-test , mtl >=2.3.1 && <3.0 , network ==3.1.* , optparse-applicative >=0.15 && <0.17 + , random >=1.1 && <1.3 , silently ==1.2.* , simple-logger ==0.1.* , simplex-chat @@ -508,6 +555,7 @@ test-suite simplex-chat-test , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* + , unicode-transforms ==0.4.* , unliftio ==0.2.* default-language: Haskell2010 if flag(client_postgres) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bf07e4ae51..e14275b75c 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,10 +40,11 @@ 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 +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C @@ -51,34 +53,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 +86,11 @@ 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, + presetDomains = [".simplex.im", ".simplexonflux.com"], tbqSize = 1024, fileChunkSize = 15780, -- do not change xftpDescrPartSize = 14000, @@ -133,53 +112,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} @@ -194,7 +126,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable, yesToUpMigrations}, deviceName, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations @@ -310,12 +242,12 @@ newChatController randomServerCfgs name p opDomains rndSrvs = toJustOrError name $ L.nonEmpty $ agentServerCfgs p opDomains $ concatMap (pServers p) rndSrvs agentServers :: DB.Connection -> ChatConfig -> NonEmpty PresetOperator -> RandomAgentServers -> IO InitialAgentServers - agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}} presetOps as = do + agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}, presetDomains} presetOps as = do users <- getUsers db ops <- getUpdateServerOperators db presetOps (null users) let opDomains = operatorDomains $ mapMaybe snd ops (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users - pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg} + pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains} where optServers :: [(UserId, NonEmpty (ServerCfg p))] -> [ProtoServerWithAuth p] -> [(UserId, NonEmpty (ServerCfg p))] optServers srvs overrides_ = case L.nonEmpty overrides_ of diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 23b5f2ddad..1efa69fad4 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -25,8 +25,6 @@ data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Sho data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show) -data OpenLinksSetting = OLSYes | OLSNo | OLSAsk deriving (Show) - data AppSettings = AppSettings { appPlatform :: Maybe AppPlatform, networkConfig :: Maybe NetworkConfig, @@ -35,7 +33,6 @@ data AppSettings = AppSettings privacyAskToApproveRelays :: Maybe Bool, privacyAcceptImages :: Maybe Bool, privacyLinkPreviews :: Maybe Bool, - privacyChatListOpenLinks :: Maybe OpenLinksSetting, privacyShowChatPreviews :: Maybe Bool, privacySaveLastDraft :: Maybe Bool, privacyProtectScreen :: Maybe Bool, @@ -86,7 +83,6 @@ defaultAppSettings = privacyAskToApproveRelays = Just True, privacyAcceptImages = Just True, privacyLinkPreviews = Just True, - privacyChatListOpenLinks = Just OLSAsk, privacyShowChatPreviews = Just True, privacySaveLastDraft = Just True, privacyProtectScreen = Just False, @@ -124,7 +120,6 @@ defaultParseAppSettings = privacyAskToApproveRelays = Nothing, privacyAcceptImages = Nothing, privacyLinkPreviews = Nothing, - privacyChatListOpenLinks = Nothing, privacyShowChatPreviews = Nothing, privacySaveLastDraft = Nothing, privacyProtectScreen = Nothing, @@ -162,7 +157,6 @@ combineAppSettings platformDefaults storedSettings = privacyAskToApproveRelays = p privacyAskToApproveRelays, privacyAcceptImages = p privacyAcceptImages, privacyLinkPreviews = p privacyLinkPreviews, - privacyChatListOpenLinks = p privacyChatListOpenLinks, privacyShowChatPreviews = p privacyShowChatPreviews, privacySaveLastDraft = p privacySaveLastDraft, privacyProtectScreen = p privacyProtectScreen, @@ -203,8 +197,6 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls) $(JQ.deriveJSON (enumJSON $ dropPrefix "NPA") ''NetworkProxyAuth) -$(JQ.deriveJSON (enumJSON $ dropPrefix "OLS") ''OpenLinksSetting) - $(JQ.deriveJSON defaultJSON ''NetworkProxy) $(JQ.deriveToJSON defaultJSON ''AppSettings) @@ -218,7 +210,6 @@ instance FromJSON AppSettings where privacyAskToApproveRelays <- p "privacyAskToApproveRelays" privacyAcceptImages <- p "privacyAcceptImages" privacyLinkPreviews <- p "privacyLinkPreviews" - privacyChatListOpenLinks <- p "privacyChatListOpenLinks" privacyShowChatPreviews <- p "privacyShowChatPreviews" privacySaveLastDraft <- p "privacySaveLastDraft" privacyProtectScreen <- p "privacyProtectScreen" @@ -253,7 +244,6 @@ instance FromJSON AppSettings where privacyAskToApproveRelays, privacyAcceptImages, privacyLinkPreviews, - privacyChatListOpenLinks, privacyShowChatPreviews, privacySaveLastDraft, privacyProtectScreen, diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 2f7e2f2abd..9b92c8b800 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE TupleSections #-} module Simplex.Chat.Bot where @@ -11,7 +12,10 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad import qualified Data.ByteString.Char8 as B -import Data.List.NonEmpty (NonEmpty (..)) +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 @@ -21,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) @@ -28,12 +33,12 @@ chatBotRepl :: String -> (Contact -> String -> IO String) -> User -> ChatControl chatBotRepl welcome answer _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - case resp of - CRContactConnected _ contact _ -> do + (_, event) <- atomically . readTBQueue $ outputQ cc + case event of + Right (CEvtContactConnected _ contact _) -> do contactConnected contact void $ sendMessage cc contact $ T.pack welcome - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do + Right CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do let msg = T.unpack $ ciContentToText mc void $ sendMessage cc contact . T.pack =<< answer contact msg _ -> pure () @@ -46,16 +51,18 @@ initializeBotAddress = initializeBotAddress' True initializeBotAddress' :: Bool -> ChatController -> IO () initializeBotAddress' logAddress cc = do sendChatCmd cc ShowMyAddress >>= \case - CRUserContactLink _ UserContactLink {connReqContact} -> showBotAddress connReqContact - CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do + Right (CRUserContactLink _ UserContactLink {connLinkContact}) -> showBotAddress connLinkContact + Left (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 + Right (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 () @@ -68,21 +75,27 @@ sendComposedMessage :: ChatController -> Contact -> Maybe ChatItemId -> MsgConte sendComposedMessage cc = sendComposedMessage' cc . contactId' sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO () -sendComposedMessage' cc ctId quotedItemId msgContent = do - let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent} - sendChatCmd cc (APISendMessages (ChatRef CTDirect ctId) False Nothing (cm :| [])) >>= \case - CRNewChatItems {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId +sendComposedMessage' cc ctId qiId mc = sendComposedMessages_ cc (SRDirect ctId) [(qiId, mc)] + +sendComposedMessages :: ChatController -> SendRef -> NonEmpty MsgContent -> IO () +sendComposedMessages cc sendRef = sendComposedMessages_ cc sendRef . L.map (Nothing,) + +sendComposedMessages_ :: ChatController -> SendRef -> NonEmpty (Maybe ChatItemId, MsgContent) -> IO () +sendComposedMessages_ cc sendRef qmcs = do + let cms = L.map (\(qiId, mc) -> ComposedMessage {fileSource = Nothing, quotedItemId = qiId, msgContent = mc, mentions = M.empty}) qmcs + sendChatCmd cc (APISendMessages sendRef False Nothing cms) >>= \case + Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent " <> show (length cms) <> " messages to " <> show sendRef r -> putStrLn $ "unexpected send message response: " <> show r deleteMessage :: ChatController -> Contact -> ChatItemId -> IO () deleteMessage cc ct chatItemId = do let cmd = APIDeleteChatItem (contactRef ct) [chatItemId] CIDMInternal sendChatCmd cc cmd >>= \case - CRChatItemsDeleted {} -> printLog cc CLLInfo $ "deleted message(s) from " <> contactInfo ct + Right (CRChatItemsDeleted {}) -> printLog cc CLLInfo $ "deleted message(s) from " <> contactInfo ct r -> putStrLn $ "unexpected delete message response: " <> show r contactRef :: Contact -> ChatRef -contactRef = ChatRef CTDirect . contactId' +contactRef ct = ChatRef CTDirect (contactId' ct) Nothing printLog :: ChatController -> ChatLogLevel -> String -> IO () printLog cc level s diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 3b1f28dd27..18398ecb3e 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -23,10 +23,10 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (Contact, ContactId, User) -import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) data Call = Call diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bf91e5ed23..016a7f3dd2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -19,7 +19,8 @@ module Simplex.Chat.Controller where import Control.Concurrent (ThreadId) import Control.Concurrent.Async (Async) -import Control.Exception +import Control.Exception (Exception, SomeException) +import qualified Control.Exception as E import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader @@ -39,6 +40,8 @@ import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) +import Data.Set (Set) import Data.String import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) @@ -47,11 +50,11 @@ import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) +import Network.Socket (HostName) import Numeric.Natural import qualified Paths_simplex_chat as SC import Simplex.Chat.AppSettings import Simplex.Chat.Call -import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -59,7 +62,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types import Simplex.Chat.Stats (PresentedServersSummary) -import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, GroupLinkInfo, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -74,7 +77,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction, withTransactionPriority) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, UpMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SocksMode (..)) +import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -82,9 +85,9 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Transport (TLS, simplexMQVersion) +import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, catchAllErrors', tryAllErrors, tryAllErrors', (<$$>)) import Simplex.RemoteControl.Client @@ -92,7 +95,6 @@ import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitatio import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) -import qualified UnliftIO.Exception as E import UnliftIO.STM #if !defined(dbPostgres) import Database.SQLite.Simple (SQLError) @@ -136,6 +138,8 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, + shortLinkPresetServers :: NonEmpty SMPServer, + presetDomains :: [HostName], tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -168,18 +172,16 @@ data ChatHooks = ChatHooks { -- preCmdHook can be used to process or modify the commands before they are processed. -- This hook should be used to process CustomChatCommand. -- if this hook returns ChatResponse, the command processing will be skipped. - preCmdHook :: ChatController -> ChatCommand -> IO (Either ChatResponse ChatCommand), + preCmdHook :: Maybe (ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand)), -- eventHook can be used to additionally process or modify events, -- it is called before the event is sent to the user (or to the UI). - eventHook :: ChatController -> ChatResponse -> IO ChatResponse + eventHook :: Maybe (ChatController -> Either ChatError ChatEvent -> IO (Either ChatError ChatEvent)), + -- acceptMember hook can be used to accept or reject member connecting via group link without API calls + acceptMember :: Maybe (GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole))) } defaultChatHooks :: ChatHooks -defaultChatHooks = - ChatHooks - { preCmdHook = \_ -> pure . Right, - eventHook = \_ -> pure - } +defaultChatHooks = ChatHooks Nothing Nothing Nothing data PresetServers = PresetServers { operators :: NonEmpty PresetOperator, @@ -221,7 +223,7 @@ data ChatController = ChatController random :: TVar ChaChaDRG, eventSeq :: TVar Int, inputQ :: TBQueue String, - outputQ :: TBQueue (Maybe CorrId, Maybe RemoteHostId, ChatResponse), + outputQ :: TBQueue (Maybe RemoteHostId, Either ChatError ChatEvent), connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, @@ -304,7 +306,7 @@ data ChatCommand | APIGetChat ChatRef (Maybe MsgContentTag) ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId - | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} + | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatTag ChatTagData | APISetChatTags ChatRef (Maybe (NonEmpty ChatTagId)) | APIDeleteChatTag ChatTagId @@ -313,9 +315,11 @@ data ChatCommand | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} | APIReportMessage {groupId :: GroupId, chatItemId :: ChatItemId, reportReason :: ReportReason, reportText :: Text} | ReportMessage {groupName :: GroupName, contactName_ :: Maybe ContactName, reportReason :: ReportReason, reportedMessage :: Text} - | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} + | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, updatedMessage :: UpdatedMessage} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) + | APIArchiveReceivedReports GroupId + | APIDeleteReceivedReports GroupId (NonEmpty ChatItemId) CIDeleteMode | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIGetReactionMembers UserId GroupId ChatItemId MsgReaction | APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId} @@ -346,25 +350,30 @@ data ChatCommand | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) - | APIParseMarkdown Text | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString + | APICheckToken DeviceToken | APIDeleteToken DeviceToken | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} - | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} + | APIGetConnNtfMessages (NonEmpty ConnMsgReq) | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} - | APIMemberRole GroupId GroupMemberId GroupMemberRole - | APIBlockMemberForAll GroupId GroupMemberId Bool - | APIRemoveMember GroupId GroupMemberId + | APIAcceptMember GroupId GroupMemberId GroupMemberRole + | APIDeleteMemberSupportChat GroupId GroupMemberId + | APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole + | APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool + | APIRemoveMembers {groupId :: GroupId, groupMemberIds :: Set GroupMemberId, withMessages :: Bool} | APILeaveGroup GroupId | APIListMembers GroupId + -- | 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 + | APIAddGroupShortLink GroupId | APICreateMemberContact GroupId GroupMemberId | APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent} | GetUserProtoServers AProtocolType @@ -434,25 +443,26 @@ 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 | ShowMyAddress + | APIAddMyAddressShortLink UserId | APISetProfileAddress UserId Bool | SetProfileAddress Bool | APIAddressAutoAccept UserId (Maybe AutoAccept) @@ -462,11 +472,11 @@ data ChatCommand | ForwardMessage {toChatName :: ChatName, fromContactName :: ContactName, forwardedMsg :: Text} | ForwardGroupMessage {toChatName :: ChatName, fromGroupName :: GroupName, fromMemberName_ :: Maybe ContactName, forwardedMsg :: Text} | ForwardLocalMessage {toChatName :: ChatName, forwardedMsg :: Text} - | SendMessage ChatName Text + | SendMessage SendName Text | SendMemberContactMessage GroupName ContactName Text | SendLiveMessage ChatName Text | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: Text, message :: Text} - | SendMessageBroadcast Text -- UserId (not used in UI) + | SendMessageBroadcast MsgContent -- UserId (not used in UI) | DeleteMessage ChatName Text | DeleteMemberMessage GroupName ContactName Text | EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text} @@ -476,20 +486,22 @@ data ChatCommand | NewGroup IncognitoEnabled GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} + | AcceptMember GroupName ContactName GroupMemberRole | MemberRole GroupName ContactName GroupMemberRole | BlockForAll GroupName ContactName Bool - | RemoveMember GroupName ContactName + | RemoveMembers {groupName :: GroupName, members :: Set ContactName, withMessages :: Bool} | LeaveGroup GroupName | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName + | ListMemberSupportChats GroupName | APIListGroups UserId (Maybe ContactId) (Maybe String) | ListGroups (Maybe ContactName) (Maybe String) | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) | ShowGroupDescription GroupName - | CreateGroupLink GroupName GroupMemberRole + | CreateGroupLink GroupName GroupMemberRole CreateShortLink | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName | ShowGroupLink GroupName @@ -518,6 +530,7 @@ data ChatCommand | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) | SetGroupFeature AGroupFeatureNoRole GroupName GroupFeatureEnabled | SetGroupFeatureRole AGroupFeatureRole GroupName GroupFeatureEnabled (Maybe GroupMemberRole) + | SetGroupMemberAdmissionReview GroupName (Maybe MemberCriteria) | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) @@ -542,7 +555,7 @@ data ChatCommand | QuitChat | ShowVersion | DebugLocks - | DebugEvent ChatResponse + | DebugEvent ChatEvent | GetAgentSubsTotal UserId | GetAgentServersSummary UserId | ResetAgentServersStats @@ -602,7 +615,6 @@ data ChatResponse | CRChatStarted | CRChatRunning | CRChatStopped - | CRChatSuspended | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo} @@ -610,7 +622,6 @@ data ChatResponse | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) - | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | CRServerOperatorConditions {conditions :: ServerOperatorConditions} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} @@ -623,46 +634,37 @@ data ChatResponse | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRQueueInfo {user :: User, rcvMsgInfo :: Maybe RcvMsgInfo, queueInfo :: ServerQueueInfo} | CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} - | CRGroupMemberSwitchStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} + | CEvtGroupMemberSwitchStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} | CRContactSwitchAborted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} - | CRGroupMemberSwitchAborted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} - | CRContactSwitch {user :: User, contact :: Contact, switchProgress :: SwitchProgress} - | CRGroupMemberSwitch {user :: User, groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} + | CEvtGroupMemberSwitchAborted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} | CRContactRatchetSyncStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} | CRGroupMemberRatchetSyncStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} - | CRContactRatchetSync {user :: User, contact :: Contact, ratchetSyncProgress :: RatchetSyncProgress} - | CRGroupMemberRatchetSync {user :: User, groupInfo :: GroupInfo, member :: GroupMember, ratchetSyncProgress :: RatchetSyncProgress} - | CRContactVerificationReset {user :: User, contact :: Contact} - | CRGroupMemberVerificationReset {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRContactCode {user :: User, contact :: Contact, connectionCode :: Text} | CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} | CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text} | CRTagsUpdated {user :: User, userTags :: [ChatTag], chatTags :: [ChatTagId]} | CRNewChatItems {user :: User, chatItems :: [AChatItem]} - | CRChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]} | CRChatItemUpdated {user :: User, chatItem :: AChatItem} | CRChatItemNotChanged {user :: User, chatItem :: AChatItem} | CRChatItemReaction {user :: User, added :: Bool, reaction :: ACIReaction} | CRReactionMembers {user :: User, memberReactions :: [MemberReaction]} | CRChatItemsDeleted {user :: User, chatItemDeletions :: [ChatItemDeletion], byUser :: Bool, timed :: Bool} | CRGroupChatItemsDeleted {user :: User, groupInfo :: GroupInfo, chatItemIDs :: [ChatItemId], byUser :: Bool, member_ :: Maybe GroupMember} - | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent {user :: User, msgContent :: MsgContent, successes :: Int, failures :: Int, timestamp :: UTCTime} - | CRMsgIntegrityError {user :: User, msgError :: MsgErrorType} - | CRCmdAccepted {corr :: CorrId} | CRCmdOk {user_ :: Maybe User} | CRChatHelp {helpSection :: HelpSection} | CRWelcome {user :: User} | CRGroupCreated {user :: User, groupInfo :: GroupInfo} | CRGroupMembers {user :: User, group :: Group} + | CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} + -- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]} + -- | CRGroupConversationsDeleted {user :: User, groupInfo :: GroupInfo, deletedGroupConversations :: [GroupConversation]} | CRContactsList {user :: User, contacts :: [Contact]} | CRUserContactLink {user :: User, contactLink :: UserContactLink} | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} - | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} - | CRBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} - | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus @@ -671,143 +673,64 @@ 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} - | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} - | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} + | CRItemsReadForChat {user :: User, chatInfo :: AChatInfo} | 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} - | CRAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} | CRContactAlreadyExists {user :: User, contact :: Contact} - | CRContactRequestAlreadyAccepted {user :: User, contact :: Contact} - | CRBusinessRequestAlreadyAccepted {user :: User, groupInfo :: GroupInfo} | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo} | CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation} - | CRRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr} | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} + -- TODO add chatItem :: AChatItem | CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} | CRStandaloneFileInfo {fileMeta :: Maybe J.Value} | CRRcvStandaloneFileCreated {user :: User, rcvFileTransfer :: RcvFileTransfer} -- returned by _download - | CRRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats - | CRRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileComplete {user :: User, chatItem :: AChatItem} - | CRRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer} | CRRcvFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileWarning {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} - | CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRSndStandaloneFileCreated {user :: User, fileTransferMeta :: FileTransferMeta} -- returned by _upload - | CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used - | CRSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} - | CRSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta} - | CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]} - | CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} - | CRSndFileWarning {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} | CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRContactConnecting {user :: User, contact :: Contact} - | CRContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} - | CRContactSndReady {user :: User, contact :: Contact} - | CRContactAnotherClient {user :: User, contact :: Contact} - | CRSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} - | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError} - | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} - | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} - | CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} - | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} - | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} - | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} - | CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRMemberRoleUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool} - | CRMemberBlockedForAllUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, blocked :: Bool} - | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} - | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} - | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} - | CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} - | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} - | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} - | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} - | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} + | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} | 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} - | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} - | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} - | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} - | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} - | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} - | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} - | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} - | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} - | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} - | CRCallInvitation {callInvitation :: RcvCallInvitation} - | CRCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} - | CRCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} - | CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} - | CRCallEnded {user :: User, contact :: Contact} | CRCallInvitations {callInvitations :: [RcvCallInvitation]} - | CRUserContactLinkSubscribed -- TODO delete - | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer} | CRNtfConns {ntfConns :: [NtfConn]} - | CRConnNtfMessages {receivedMsgs :: NonEmpty (Maybe NtfMsgInfo)} - | CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgAckInfo} + | CRConnNtfMessages {receivedMsgs :: NonEmpty RcvNtfMsgInfo} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String, localAddrs :: NonEmpty RCCtrlAddress} - | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} - | CRNewRemoteHost {remoteHost :: RemoteHostInfo} - | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} - | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId, rhsState :: RemoteHostSessionState, rhStopReason :: RemoteHostStopReason} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} - | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} - | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} - | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} #if !defined(dbPostgres) | CRArchiveExported {archiveErrors :: [ArchiveError]} @@ -822,67 +745,186 @@ data ChatResponse | CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]} | CRAgentSubsDetails {agentSubs :: SubscriptionsInfo} | CRAgentQueuesInfo {agentQueuesInfo :: AgentQueuesInfo} - | CRContactDisabled {user :: User, contact :: Contact} - | CRConnectionDisabled {connectionEntity :: ConnectionEntity} - | CRConnectionInactive {connectionEntity :: ConnectionEntity, inactive :: Bool} - | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} - | CRAgentConnDeleted {agentConnId :: AgentConnId} - | CRAgentUserDeleted {agentUserId :: Int64} - | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} - | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} - | CRChatError {user_ :: Maybe User, chatError :: ChatError} - | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} | CRAppSettings {appSettings :: AppSettings} - | CRTimedAction {action :: String, durationMilliseconds :: Int64} | CRCustomChatResponse {user_ :: Maybe User, response :: Text} deriving (Show) --- some of these can only be used as command responses -allowRemoteEvent :: ChatResponse -> Bool +data ChatEvent + = CEvtChatSuspended + | CEvtContactSwitch {user :: User, contact :: Contact, switchProgress :: SwitchProgress} + | CEvtGroupMemberSwitch {user :: User, groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} + | CEvtContactRatchetSync {user :: User, contact :: Contact, ratchetSyncProgress :: RatchetSyncProgress} + | CEvtGroupMemberRatchetSync {user :: User, groupInfo :: GroupInfo, member :: GroupMember, ratchetSyncProgress :: RatchetSyncProgress} + | CEvtNewChatItems {user :: User, chatItems :: [AChatItem]} -- there is the same command response + | CEvtChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]} + | CEvtChatItemUpdated {user :: User, chatItem :: AChatItem} -- there is the same command response + | CEvtChatItemNotChanged {user :: User, chatItem :: AChatItem} -- there is the same command response + | CEvtChatItemReaction {user :: User, added :: Bool, reaction :: ACIReaction} -- there is the same command response + | CEvtGroupChatItemsDeleted {user :: User, groupInfo :: GroupInfo, chatItemIDs :: [ChatItemId], byUser :: Bool, member_ :: Maybe GroupMember} -- there is the same command response + | CEvtChatItemsDeleted {user :: User, chatItemDeletions :: [ChatItemDeletion], byUser :: Bool, timed :: Bool} -- there is the same command response + | CEvtChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} + | CEvtUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} -- there is the same command response + | CEvtGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CEvtBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} + | CEvtSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} -- there is the same command response + | CEvtContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CEvtGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} + | CEvtContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} + | CEvtContactDeletedByContact {user :: User, contact :: Contact} + | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} + | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response + | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} + | CEvtContactRequestAlreadyAccepted {user :: User, contact :: Contact} + | CEvtBusinessRequestAlreadyAccepted {user :: User, groupInfo :: GroupInfo} + | CEvtRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr} + | CEvtRcvFileAccepted {user :: User, chatItem :: AChatItem} -- there is the same command response + -- TODO add chatItem :: AChatItem + | CEvtRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} -- there is the same command response + | CEvtRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats + | CEvtRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileComplete {user :: User, chatItem :: AChatItem} + | CEvtRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileWarning {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} + | CEvtSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CEvtSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CEvtSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer} + | CEvtSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used + | CEvtSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} + | CEvtSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta} + | CEvtSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} + | CEvtSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]} + | CEvtSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} + | CEvtSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} + | CEvtSndFileWarning {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} + | CEvtContactConnecting {user :: User, contact :: Contact} + | CEvtContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} + | CEvtContactSndReady {user :: User, contact :: Contact} + | CEvtContactAnotherClient {user :: User, contact :: Contact} + | CEvtSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} + | CEvtContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} + | CEvtContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} + | CEvtContactSubError {user :: User, contact :: Contact, chatError :: ChatError} + | CEvtContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} + | CEvtUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} + | CEvtNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} + | CEvtNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} -- there is the same command response + | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} + | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} + | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} + | CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CEvtJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- there is the same command response + | CEvtJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} + | CEvtMemberAcceptedByOther {user :: User, groupInfo :: GroupInfo, acceptingMember :: GroupMember, member :: GroupMember} + | CEvtMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} + | CEvtMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool} + | CEvtConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} + | CEvtDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember, withMessages :: Bool} + | CEvtDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, withMessages :: Bool} + | CEvtLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} + | CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} + | CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} + | CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} -- there is the same command response + | CEvtAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI + | CEvtNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} + | CEvtCallInvitation {callInvitation :: RcvCallInvitation} + | CEvtCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} + | CEvtCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} + | CEvtCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} + | CEvtCallEnded {user :: User, contact :: Contact} + | CEvtNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgAckInfo} + | CEvtRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} + | CEvtNewRemoteHost {remoteHost :: RemoteHostInfo} + | CEvtRemoteHostConnected {remoteHost :: RemoteHostInfo} + | CEvtRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId, rhsState :: RemoteHostSessionState, rhStopReason :: RemoteHostStopReason} + | CEvtRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} + | CEvtRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} + | CEvtRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} + | CEvtContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} + | CEvtContactDisabled {user :: User, contact :: Contact} + | CEvtConnectionDisabled {connectionEntity :: ConnectionEntity} + | CEvtConnectionInactive {connectionEntity :: ConnectionEntity, inactive :: Bool} + | CEvtAgentRcvQueuesDeleted {deletedRcvQueues :: NonEmpty DeletedRcvQueue} + | CEvtAgentConnsDeleted {agentConnIds :: NonEmpty AgentConnId} + | CEvtAgentUserDeleted {agentUserId :: Int64} + | CEvtMessageError {user :: User, severity :: Text, errorMessage :: Text} + | CEvtChatErrors {chatErrors :: [ChatError]} + | CEvtTimedAction {action :: String, durationMilliseconds :: Int64} + | CEvtTerminalEvent TerminalEvent + | CEvtCustomChatEvent {user_ :: Maybe User, response :: Text} + deriving (Show) + +data TerminalEvent + = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} + | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} + | TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | TEContactVerificationReset {user :: User, contact :: Contact} + | TEGroupMemberVerificationReset {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | TEGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} + | TEGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} + | TEGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} + | TEMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} + | TEMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} + | TEPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} + | TESndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} + | TERcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} + deriving (Show) + +data DeletedRcvQueue = DeletedRcvQueue + { agentConnId :: AgentConnId, + server :: SMPServer, + agentQueueId :: AgentQueueId, + agentError_ :: Maybe AgentErrorType + } + deriving (Show) + +allowRemoteEvent :: ChatEvent -> Bool allowRemoteEvent = \case - CRChatStarted -> False - CRChatRunning -> False - CRChatStopped -> False - CRChatSuspended -> False - CRRemoteHostList _ -> False - CRCurrentRemoteHost _ -> False - CRRemoteHostStarted {} -> False - CRRemoteHostSessionCode {} -> False - CRNewRemoteHost _ -> False - CRRemoteHostConnected _ -> False - CRRemoteHostStopped {} -> False - CRRemoteFileStored {} -> False - CRRemoteCtrlList _ -> False - CRRemoteCtrlFound {} -> False - CRRemoteCtrlConnecting {} -> False - CRRemoteCtrlSessionCode {} -> False - CRRemoteCtrlConnected _ -> False - CRRemoteCtrlStopped {} -> False - CRSQLResult _ -> False -#if !defined(dbPostgres) - CRSlowSQLQueries {} -> False -#endif + CEvtChatSuspended -> False + CEvtRemoteHostSessionCode {} -> False + CEvtNewRemoteHost _ -> False + CEvtRemoteHostConnected _ -> False + CEvtRemoteHostStopped {} -> False + CEvtRemoteCtrlFound {} -> False + CEvtRemoteCtrlSessionCode {} -> False + CEvtRemoteCtrlStopped {} -> False _ -> True -logResponseToFile :: ChatResponse -> Bool -logResponseToFile = \case - CRContactsDisconnected {} -> True - CRContactsSubscribed {} -> True - CRContactSubError {} -> True - CRMemberSubError {} -> True - CRSndFileSubError {} -> True - CRRcvFileSubError {} -> True - CRHostConnected {} -> True - CRHostDisconnected {} -> True - CRConnectionDisabled {} -> True - CRAgentRcvQueueDeleted {} -> True - CRAgentConnDeleted {} -> True - CRAgentUserDeleted {} -> True - CRChatCmdError {} -> True - CRChatError {} -> True - CRMessageError {} -> True +logEventToFile :: ChatEvent -> Bool +logEventToFile = \case + CEvtContactsDisconnected {} -> True + CEvtContactsSubscribed {} -> True + CEvtContactSubError {} -> True + CEvtHostConnected {} -> True + CEvtHostDisconnected {} -> True + CEvtConnectionDisabled {} -> True + CEvtAgentRcvQueuesDeleted {} -> True + CEvtAgentConnsDeleted {} -> True + CEvtAgentUserDeleted {} -> True + -- CRChatCmdError {} -> True -- TODO this should be separately logged to file as command error + CEvtMessageError {} -> True + CEvtTerminalEvent te -> case te of + TEMemberSubError {} -> True + TESndFileSubError {} -> True + TERcvFileSubError {} -> True + _ -> False _ -> False +data SendRef + = SRDirect ContactId + | SRGroup GroupId (Maybe GroupChatScope) + deriving (Eq, Show) + +sendToChatRef :: SendRef -> ChatRef +sendToChatRef = \case + SRDirect cId -> ChatRef CTDirect cId Nothing + SRGroup gId scope -> ChatRef CTGroup gId scope + data ChatPagination = CPLast Int | CPAfter ChatItemId Int @@ -915,6 +957,7 @@ data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} | CPGroupLink {groupLinkPlan :: GroupLinkPlan} + | CPError {chatError :: ChatError} deriving (Show) data InvitationLinkPlan @@ -958,6 +1001,7 @@ connectionPlanProceed = \case GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True _ -> False + CPError _ -> True data ForwardConfirmation = FCFilesNotAccepted {fileIds :: [FileTransferId]} @@ -1024,7 +1068,7 @@ data SimpleNetCfg = SimpleNetCfg requiredHostMode :: Bool, smpProxyMode_ :: Maybe SMPProxyMode, smpProxyFallback_ :: Maybe SMPProxyFallback, - smpWebPort :: Bool, + smpWebPortServers :: SMPWebPortServers, tcpTimeout_ :: Maybe Int, logTLSErrors :: Bool } @@ -1039,7 +1083,7 @@ defaultSimpleNetCfg = requiredHostMode = False, smpProxyMode_ = Nothing, smpProxyFallback_ = Nothing, - smpWebPort = False, + smpWebPortServers = SWPPreset, tcpTimeout_ = Nothing, logTLSErrors = False } @@ -1051,7 +1095,7 @@ data ContactSubStatus = ContactSubStatus deriving (Show) data MemberSubStatus = MemberSubStatus - { member :: GroupMember, + { member :: ShortGroupMember, memberError :: Maybe ChatError } deriving (Show) @@ -1078,22 +1122,16 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary data ComposedMessage = ComposedMessage { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, - msgContent :: MsgContent + msgContent :: MsgContent, + mentions :: Map MemberName GroupMemberId } deriving (Show) --- This instance is needed for backward compatibility, can be removed in v6.0 -instance FromJSON ComposedMessage where - parseJSON (J.Object v) = do - fileSource <- - (v .:? "fileSource") >>= \case - Nothing -> CF.plain <$$> (v .:? "filePath") - f -> pure f - quotedItemId <- v .:? "quotedItemId" - msgContent <- v .: "msgContent" - pure ComposedMessage {fileSource, quotedItemId, msgContent} - parseJSON invalid = - JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) +data UpdatedMessage = UpdatedMessage + { msgContent :: MsgContent, + mentions :: Map MemberName GroupMemberId + } + deriving (Show) data ChatTagData = ChatTagData { emoji :: Maybe Text, @@ -1106,17 +1144,30 @@ instance FromJSON ChatTagData where parseJSON invalid = JT.prependFailure "bad ChatTagData, " (JT.typeMismatch "Object" invalid) data NtfConn = NtfConn - { user_ :: Maybe User, - connEntity_ :: Maybe ConnectionEntity, + { user :: User, + agentConnId :: AgentConnId, + agentDbQueueId :: Int64, + connEntity :: ConnectionEntity, + -- Decrypted ntf meta of the expected message (the one notification was sent for). + -- Nothing means it failed to decrypt or to decode, we can still show event for entity expectedMsg_ :: Maybe NtfMsgInfo } deriving (Show) +-- msgTs is broker message timestamp, it is used in ConnMsgReq / APIGetConnNtfMessages +-- to set it as last connection message in case queue is empty data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) -receivedMsgInfo :: SMPMsgMeta -> NtfMsgInfo -receivedMsgInfo SMPMsgMeta {msgId, msgTs} = ntfMsgInfo_ msgId msgTs +data RcvNtfMsgInfo + = RNMInfo {ntfMsgInfo :: Maybe NtfMsgInfo} + | RNMError {ntfMsgError :: AgentErrorType} + deriving (Show) + +receivedMsgInfo :: Either AgentErrorType (Maybe SMPMsgMeta) -> RcvNtfMsgInfo +receivedMsgInfo = \case + Right msgMeta_ -> RNMInfo $ (\SMPMsgMeta {msgId, msgTs} -> ntfMsgInfo_ msgId msgTs) <$> msgMeta_ + Left e -> RNMError e expectedMsgInfo :: NMsgMeta -> NtfMsgInfo expectedMsgInfo NMsgMeta {msgId, msgTs} = ntfMsgInfo_ msgId msgTs @@ -1227,8 +1278,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} @@ -1266,7 +1317,6 @@ data ChatErrorType | CEFileNotApproved {fileId :: FileTransferId, unknownServers :: [XFTPServer]} | CEFallbackToSMPProhibited {fileId :: FileTransferId} | CEInlineFileProhibited {fileId :: FileTransferId} - | CEInvalidQuote | CEInvalidForward | CEInvalidChatItemUpdate | CEInvalidChatItemDelete @@ -1365,7 +1415,7 @@ data RemoteCtrlSession { remoteCtrlId_ :: Maybe RemoteCtrlId, ctrlDeviceName :: Text, rcsClient :: RCCtrlClient, - tls :: TLS, + tls :: TLS 'TClient, sessionCode :: Text, rcsWaitSession :: Async (), rcsWaitConfirmation :: TMVar (Either RCErrorType (RCCtrlSession, RCCtrlPairing)) @@ -1373,10 +1423,10 @@ data RemoteCtrlSession | RCSessionConnected { remoteCtrlId :: RemoteCtrlId, rcsClient :: RCCtrlClient, - tls :: TLS, + tls :: TLS 'TClient, rcsSession :: RCCtrlSession, http2Server :: Async (), - remoteOutputQ :: TBQueue ChatResponse + remoteOutputQ :: TBQueue (Either ChatError ChatEvent) } data RemoteCtrlSessionState @@ -1475,27 +1525,51 @@ mkStoreError :: SomeException -> StoreError mkStoreError = SEInternalError . show {-# INLINE mkStoreError #-} -chatCmdError :: Maybe User -> String -> ChatResponse -chatCmdError user = CRChatCmdError user . ChatError . CECommandError +throwCmdError :: String -> CM a +throwCmdError = throwError . ChatError . CECommandError +{-# INLINE throwCmdError #-} + +chatCmdError :: String -> Either ChatError ChatResponse +chatCmdError = Left . ChatError . CECommandError +{-# INLINE chatCmdError #-} throwChatError :: ChatErrorType -> CM a throwChatError = throwError . ChatError +{-# INLINE throwChatError #-} + +toViewTE :: TerminalEvent -> CM () +toViewTE = toView . CEvtTerminalEvent +{-# INLINE toViewTE #-} -- | Emit local events. -toView :: ChatResponse -> CM () +toView :: ChatEvent -> CM () toView = lift . toView' {-# INLINE toView #-} -toView' :: ChatResponse -> CM' () -toView' ev = do +toView' :: ChatEvent -> CM' () +toView' = toView_ . Right +{-# INLINE toView' #-} + +eToView :: ChatError -> CM () +eToView = lift . eToView' +{-# INLINE eToView #-} + +eToView' :: ChatError -> CM' () +eToView' = toView_ . Left +{-# INLINE eToView' #-} + +toView_ :: Either ChatError ChatEvent -> CM' () +toView_ ev = do cc@ChatController {outputQ = localQ, remoteCtrlSession = session, config = ChatConfig {chatHooks}} <- ask - event <- liftIO $ eventHook chatHooks cc ev + event <- case eventHook chatHooks of + Just hook -> liftIO $ hook cc ev + Nothing -> pure ev atomically $ readTVar session >>= \case Just (_, RCSessionConnected {remoteOutputQ}) - | allowRemoteEvent event -> writeTBQueue remoteOutputQ event + | either (const True) allowRemoteEvent event -> writeTBQueue remoteOutputQ event -- TODO potentially, it should hold some events while connecting - _ -> writeTBQueue localQ (Nothing, Nothing, event) + _ -> writeTBQueue localQ (Nothing, event) withStore' :: (DB.Connection -> IO a) -> CM a withStore' action = withStore $ liftIO . action @@ -1524,7 +1598,7 @@ withStoreBatch actions = do liftIO $ withTransaction chatStore $ mapM (`E.catches` handleDBErrors) . actions -- TODO [postgres] postgres specific error handling -handleDBErrors :: [E.Handler IO (Either ChatError a)] +handleDBErrors :: [E.Handler (Either ChatError a)] handleDBErrors = #if !defined(dbPostgres) ( E.Handler $ \(e :: SQLError) -> @@ -1558,8 +1632,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) @@ -1574,6 +1646,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) @@ -1590,6 +1664,8 @@ $(JQ.deriveJSON defaultJSON ''UserProfileUpdateSummary) $(JQ.deriveJSON defaultJSON ''NtfMsgInfo) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RNM") ''RcvNtfMsgInfo) + $(JQ.deriveJSON defaultJSON ''NtfConn) $(JQ.deriveJSON defaultJSON ''NtfMsgAckInfo) @@ -1598,6 +1674,8 @@ $(JQ.deriveJSON defaultJSON ''SwitchProgress) $(JQ.deriveJSON defaultJSON ''RatchetSyncProgress) +$(JQ.deriveJSON defaultJSON ''DeletedRcvQueue) + $(JQ.deriveJSON defaultJSON ''ServerAddress) $(JQ.deriveJSON defaultJSON ''ParsedServerAddress) @@ -1610,29 +1688,6 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery) #endif --- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where --- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) - --- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where --- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) - --- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where --- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) --- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) - --- instance FromJSON AUserProtoServers where --- parseJSON v = J.withObject "AUserProtoServers" parse v --- where --- parse o = do --- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" --- case userProtocol p of --- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v --- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p - --- instance ToJSON AUserProtoServers where --- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s --- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) $(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) @@ -1641,12 +1696,31 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CEvt") ''ChatEvent) + $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) $(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) $(JQ.deriveToJSON defaultJSON ''ComposedMessage) +instance FromJSON ComposedMessage where + parseJSON (J.Object v) = do + fileSource <- + (v .:? "fileSource") >>= \case + Nothing -> CF.plain <$$> (v .:? "filePath") + f -> pure f + quotedItemId <- v .:? "quotedItemId" + msgContent <- v .: "msgContent" + mentions <- fromMaybe M.empty <$> v .:? "mentions" + pure ComposedMessage {fileSource, quotedItemId, msgContent, mentions} + parseJSON invalid = + JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) + +$(JQ.deriveJSON defaultJSON ''UpdatedMessage) + $(JQ.deriveToJSON defaultJSON ''ChatTagData) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 0dbee1542e..865eb6a760 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -8,6 +8,7 @@ module Simplex.Chat.Core runSimplexChat, sendChatCmdStr, sendChatCmd, + printResponseEvent, ) where @@ -23,9 +24,10 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) +import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Store.Profiles import Simplex.Chat.Types -import Simplex.Chat.View (serializeChatResponse) +import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import System.Exit (exitFailure) @@ -34,7 +36,7 @@ import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}, maintenance} chat = case logAgent of Just level -> do setLogLevel level @@ -48,7 +50,8 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core exitFailure run db@ChatDatabase {chatStore} = do u_ <- getSelectActiveUser chatStore - cc <- newChatController db u_ cfg opts False + let backgroundMode = not maintenance + cc <- newChatController db u_ cfg opts backgroundMode u <- maybe (createActiveUser cc) pure u_ unless testView $ putStrLn $ "Current user: " <> userStr u runSimplexChat opts u cc chat @@ -61,10 +64,10 @@ runSimplexChat ChatOpts {maintenance} u cc chat a2 <- async $ chat u cc waitEither_ a1 a2 -sendChatCmdStr :: ChatController -> String -> IO ChatResponse +sendChatCmdStr :: ChatController -> String -> IO (Either ChatError ChatResponse) sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc -sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse +sendChatCmd :: ChatController -> ChatCommand -> IO (Either ChatError ChatResponse) sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc getSelectActiveUser :: DBStore -> IO (Maybe User) @@ -106,12 +109,17 @@ createActiveUser cc = do displayName <- T.pack <$> getWithPrompt "display name" let profile = Just Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} execChatCommand' (CreateActiveUser NewUser {profile, pastTimestamp = False}) `runReaderT` cc >>= \case - CRActiveUser user -> pure user - r -> do - ts <- getCurrentTime - tz <- getCurrentTimeZone - putStrLn $ serializeChatResponse (Nothing, Nothing) ts tz Nothing r - loop + Right (CRActiveUser user) -> pure user + r -> printResponseEvent (Nothing, Nothing) (config cc) r >> loop + +printResponseEvent :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Either ChatError r -> IO () +printResponseEvent hu cfg = \case + Right r -> do + ts <- getCurrentTime + tz <- getCurrentTimeZone + putStrLn $ serializeChatResponse hu cfg ts tz (fst hu) r + Left e -> do + putStrLn $ serializeChatError True cfg e getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b8bf879caa..1653c33aaa 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -27,6 +27,7 @@ import Control.Monad.Reader import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.Attoparsec.Combinator as A import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -37,12 +38,13 @@ import Data.Either (fromRight, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith4) +import Data.List (dropWhileEnd, find, foldl', isSuffixOf, partition, sortOn, zipWith4) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) +import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -80,17 +82,18 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (liftIOEither) +import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB -import qualified Simplex.Messaging.Agent.Store.Migrations as Migrations -import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) +import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) +import Simplex.Messaging.Client (NetworkConfig (..), SMPWebPortServers (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -131,9 +134,9 @@ import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) _defaultNtfServers :: [NtfServer] _defaultNtfServers = - [ "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" - -- "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", - -- "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion" + [ -- "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" + "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", + "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion" ] maxImageSize :: Integer @@ -195,7 +198,7 @@ startChatController mainApp enableSndFiles = do startExpireCIThread user setExpireCIFlag user True where - shouldExpireChats = + shouldExpireChats = fmap (fromRight False) $ runExceptT $ withStore' $ \db -> do ttl <- getChatItemTTL db user ttlCount <- getChatTTLCount db user @@ -224,8 +227,8 @@ startReceiveUserFiles :: User -> CM () startReceiveUserFiles user = do filesToReceive <- withStore' (`getRcvFilesToReceive` user) forM_ filesToReceive $ \ft -> - flip catchChatError (toView . CRChatError (Just user)) $ - toView =<< receiveFile' user ft False Nothing Nothing + flip catchChatError eToView $ + toView =<< receiveFileEvt' user ft False Nothing Nothing restoreCalls :: CM' () restoreCalls = do @@ -254,11 +257,11 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, atomically $ writeTVar files M.empty updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig -updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} = +updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPortServers, tcpTimeout_, logTLSErrors} = let cfg1 = maybe cfg (\smpProxyMode -> cfg {smpProxyMode}) smpProxyMode_ cfg2 = maybe cfg1 (\smpProxyFallback -> cfg1 {smpProxyFallback}) smpProxyFallback_ cfg3 = maybe cfg2 (\tcpTimeout -> cfg2 {tcpTimeout, tcpConnectTimeout = (tcpTimeout * 3) `div` 2}) tcpTimeout_ - in cfg3 {socksProxy, socksMode, hostMode, requiredHostMode, smpWebPort, logTLSErrors} + in cfg3 {socksProxy, socksMode, hostMode, requiredHostMode, smpWebPortServers, logTLSErrors} useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)) useServers as opDomains uss = @@ -266,30 +269,28 @@ useServers as opDomains uss = xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss in (smp', xftp') -execChatCommand :: Maybe RemoteHostId -> ByteString -> CM' ChatResponse -execChatCommand rh s = do - u <- readTVarIO =<< asks currentUser +execChatCommand :: Maybe RemoteHostId -> ByteString -> CM' (Either ChatError ChatResponse) +execChatCommand rh s = case parseChatCommand s of - Left e -> pure $ chatCmdError u e + Left e -> pure $ chatCmdError e Right cmd -> case rh of Just rhId - | allowRemoteCommand cmd -> execRemoteCommand u rhId cmd s - | otherwise -> pure $ CRChatCmdError u $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand + | allowRemoteCommand cmd -> execRemoteCommand rhId cmd s + | otherwise -> pure $ Left $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand _ -> do cc@ChatController {config = ChatConfig {chatHooks}} <- ask - liftIO (preCmdHook chatHooks cc cmd) >>= either pure (execChatCommand_ u) + case preCmdHook chatHooks of + Just hook -> liftIO (hook cc cmd) >>= either pure execChatCommand' + Nothing -> execChatCommand' cmd -execChatCommand' :: ChatCommand -> CM' ChatResponse -execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) +execChatCommand' :: ChatCommand -> CM' (Either ChatError ChatResponse) +execChatCommand' cmd = handleCommandError $ processChatCommand cmd -execChatCommand_ :: Maybe User -> ChatCommand -> CM' ChatResponse -execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd +execRemoteCommand :: RemoteHostId -> ChatCommand -> ByteString -> CM' (Either ChatError ChatResponse) +execRemoteCommand rhId cmd s = handleCommandError $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s -execRemoteCommand :: Maybe User -> RemoteHostId -> ChatCommand -> ByteString -> CM' ChatResponse -execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s - -handleCommandError :: Maybe User -> CM ChatResponse -> CM' ChatResponse -handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catches` ioErrors) +handleCommandError :: CM ChatResponse -> CM' (Either ChatError ChatResponse) +handleCommandError a = runExceptT a `E.catches` ioErrors where ioErrors = [ E.Handler $ \(e :: ExitCode) -> E.throwIO e, @@ -499,23 +500,23 @@ processChatCommand' vr = \case pure $ CRChatTags user tags APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) - unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRApiChats user previews - APIGetChat (ChatRef cType cId) contentFilter pagination search -> withUser $ \user -> case cType of + APIGetChat (ChatRef cType cId scope_) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do - when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" + when (isJust contentFilter) $ throwCmdError "content filter not supported" (directChat, navInfo) <- withFastStore (\db -> getDirectChat db vr user cId pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do - (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId contentFilter pagination search) + (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search) pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo CTLocal -> do - when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" + when (isJust contentFilter) $ throwCmdError "content filter not supported" (localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) pure $ CRApiChat user (AChat SCTLocal localChat) navInfo - CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not implemented" + CTContactConnection -> throwCmdError "not supported" APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search pure $ CRChatItems user Nothing chatItems @@ -532,31 +533,33 @@ processChatCommand' vr = \case getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of Just (CIFFContact _ _ (Just ctId) (Just fwdItemId)) -> - Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTDirect ctId) fwdItemId) + Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTDirect ctId Nothing) fwdItemId) Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> - Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) + -- TODO [knocking] getAChatItem doesn't differentiate how to read based on scope - it should, instead of using group filter + Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId Nothing) fwdItemId) _ -> pure Nothing - APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of - CTDirect -> + APISendMessages sendRef live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case sendRef of + SRDirect chatId -> do + mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ - sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms) - CTGroup -> - withGroupLock "sendMessage" chatId $ - sendGroupContentMessages user chatId live itemTTL (L.map (,Nothing) cms) - CTLocal -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) + SRGroup chatId gsScope -> + withGroupLock "sendMessage" chatId $ do + (gInfo, cmrs) <- withFastStore $ \db -> do + g <- getGroupInfo db vr user chatId + (g,) <$> mapM (composedMessageReqMentions db user g) cms + sendGroupContentMessages user gInfo gsScope live itemTTL cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user - APISetChatTags (ChatRef cType chatId) tagIds -> withUser $ \user -> withFastStore' $ \db -> case cType of - CTDirect -> do + APISetChatTags (ChatRef cType chatId scope) tagIds -> withUser $ \user -> case cType of + CTDirect -> withFastStore' $ \db -> do updateDirectChatTags db chatId (maybe [] L.toList tagIds) CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId - CTGroup -> do + CTGroup | isNothing scope -> withFastStore' $ \db -> do updateGroupChatTags db chatId (maybe [] L.toList tagIds) CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId - _ -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "not supported" APIDeleteChatTag tagId -> withUser $ \user -> do withFastStore' $ \db -> deleteChatTag db user tagId ok user @@ -567,28 +570,22 @@ processChatCommand' vr = \case withFastStore' $ \db -> reorderChatTags db user $ L.toList tagIds ok user APICreateChatItems folderId cms -> withUser $ \user -> do - mapM_ assertAllowedContent' cms - createNoteFolderContentItems user folderId (L.map (,Nothing) cms) + forM_ cms $ \cm -> assertAllowedContent' cm >> assertNoMentions cm + createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do - (gInfo, ms) <- - withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user gId - (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) - let ms' = filter compatibleModerator ms - mc = MCReport reportText reportReason - cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc} - when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" - sendGroupContentMessages_ user gInfo ms' False Nothing [(cm, Nothing)] - where - compatibleModerator GroupMember {activeConn, memberChatVRange} = - maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion + gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + let mc = MCReport reportText reportReason + cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} + -- TODO [knocking] reports sent to support scope may be wrong + sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False Nothing [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage processChatCommand $ APIReportMessage gId reportedItemId reportReason "" - APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> assertAllowedContent mc >> case cType of + APIUpdateChatItem (ChatRef cType chatId scope) itemId live (UpdatedMessage mc mentions) -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do + unless (null mentions) $ throwCmdError "mentions are not supported in this chat" ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withFastStore $ \db -> getDirectCIWithReactions db user ct itemId @@ -599,45 +596,55 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + let event = XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) Nothing + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) let edited = itemLive /= Just True updateDirectChatItem' db user contactId ci (CISndMsgContent mc) edited live Nothing $ Just msgId - startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' + startUpdatedTimedItemThread user (ChatRef CTDirect contactId Nothing) ci ci' pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> withGroupLock "updateChatItem" chatId $ do - Group gInfo@GroupInfo {groupId, membership} ms <- withFastStore $ \db -> getGroup db vr user chatId - assertUserGroupRole gInfo GRAuthor - if prohibitedSimplexLinks gInfo membership mc - then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) + gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId + when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor + let (_, ft_) = msgContentTexts mc + if prohibitedSimplexLinks gInfo membership ft_ + then throwCmdError ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do + -- TODO [knocking] check chat item scope? cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do + (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + ciMentions <- withFastStore $ \db -> getCIMentions db user gInfo ft_ mentions + let msgScope = toMsgScope gInfo <$> chatScopeInfo + mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions + event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope + SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) let edited = itemLive /= Just True - updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId - startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') - else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) + ci' <- updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId + updateGroupCIMentions db gInfo ci' ciMentions + startUpdatedTimedItemThread user (ChatRef CTGroup groupId scope) ci ci' + pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo) ci') + else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTLocal -> do + unless (null mentions) $ throwCmdError "mentions are not supported in this chat" (nf@NoteFolder {noteFolderId}, cci) <- withFastStore $ \db -> (,) <$> getNoteFolder db user chatId <*> getLocalChatItem db user chatId itemId case cci of CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent oldMC} @@ -648,14 +655,14 @@ processChatCommand' vr = \case ci' <- updateLocalChatItem' db user noteFolderId ci (CISndMsgContent mc) True pure $ CRChatItemUpdated user (AChatItem SCTLocal SMDSnd (LocalChat nf) ci') _ -> throwChatError CEInvalidChatItemUpdate - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" + APIDeleteChatItem (ChatRef cType chatId scope) itemIds mode -> withUser $ \user -> case cType of CTDirect -> withContactLock "deleteChatItem" chatId $ do (ct, items) <- getCommandDirectChatItems user chatId itemIds - case mode of - CIDMInternal -> deleteDirectCIs user ct items True False - CIDMInternalMark -> markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime + deletions <- case mode of + CIDMInternal -> deleteDirectCIs user ct items + CIDMInternalMark -> markDirectCIsDeleted user ct items =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ @@ -664,26 +671,33 @@ processChatCommand' vr = \case forM_ (L.nonEmpty events) $ \events' -> sendDirectContactMessages user ct events' if featureAllowed SCFFullDelete forUser ct - then deleteDirectCIs user ct items True False - else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime + then deleteDirectCIs user ct items + else markDirectCIsDeleted user ct items =<< liftIO getCurrentTime + pure $ CRChatItemsDeleted user deletions True False CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - case mode of - CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime - CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime + -- TODO [knocking] check scope for all items? + deletions <- case mode of + CIDMInternal -> do + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + deleteGroupCIs user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> do + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + markGroupCIsDeleted user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do + (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds - mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items Nothing + mapM_ (sendGroupMessages user gInfo Nothing recipients) events + delGroupChatItems user gInfo chatScopeInfo items False + pure $ CRChatItemsDeleted user deletions True False CTLocal -> do (nf, items) <- getCommandLocalChatItems user chatId itemIds deleteLocalCIs user nf items True False - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where assertDeletable :: forall c. ChatTypeI c => [CChatItem c] -> CM () assertDeletable items = do @@ -699,41 +713,43 @@ processChatCommand' vr = \case itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do - (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds + (gInfo, items) <- getCommandGroupChatItems user gId itemIds + -- TODO [knocking] check scope is Nothing for all items? (prohibit moderation in support chats?) ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - assertDeletable gInfo items - assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate - let msgMemIds = itemsMsgMemIds gInfo items - events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds - mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items (Just membership) + let recipients = filter memberCurrent ms + deletions <- delGroupChatItemsForMembers user gInfo Nothing recipients items + pure $ CRChatItemsDeleted user deletions True False + APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do + g <- getGroupInfo db vr user gId + deleteTs <- liftIO getCurrentTime + ciIds <- liftIO $ markReceivedGroupReportsDeleted db user g deleteTs + pure $ CRGroupChatItemsDeleted user g ciIds True (Just $ membership g) + APIDeleteReceivedReports gId itemIds mode -> withUser $ \user -> withGroupLock "deleteReports" gId $ do + (gInfo, items) <- getCommandGroupChatItems user gId itemIds + unless (all isRcvReport items) $ throwCmdError "some items are not received reports" + -- TODO [knocking] scope can be different for each item if reports are from different members + -- TODO (currently we pass Nothing as scope which is wrong) + deletions <- case mode of + CIDMInternal -> deleteGroupCIs user gInfo Nothing items Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo Nothing items Nothing =<< liftIO getCurrentTime + CIDMBroadcast -> do + ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let recipients = filter memberCurrent ms + delGroupChatItemsForMembers user gInfo Nothing recipients items + pure $ CRChatItemsDeleted user deletions True False where - assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () - assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items = - unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete - where - itemDeletable :: CChatItem 'CTGroup -> Bool - itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = - case chatDir of - CIGroupRcv GroupMember {memberRole} -> membershipMemRole >= memberRole && isJust itemSharedMsgId - CIGroupSnd -> isJust itemSharedMsgId - itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, MemberId)] - itemsMsgMemIds GroupInfo {membership = GroupMember {memberId = membershipMemId}} = mapMaybe itemMsgMemIds - where - itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, MemberId) - itemMsgMemIds (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = - join <$> forM itemSharedMsgId $ \msgId -> Just $ case chatDir of - CIGroupRcv GroupMember {memberId} -> (msgId, memberId) - CIGroupSnd -> (msgId, membershipMemId) - APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> case cType of + isRcvReport = \case + CChatItem _ ChatItem {content = CIRcvMsgContent (MCReport {})} -> True + _ -> False + APIChatItemReaction (ChatRef cType chatId scope) itemId add reaction -> withUser $ \user -> case cType of CTDirect -> withContactLock "chatItemReaction" chatId $ withFastStore (\db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId) >>= \case (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (featureAllowed SCFReactions forUser ct) $ - throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) + throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) unless (ciReactionAllowed ci) $ - throwChatError (CECommandError "reaction not allowed - chat item has no content") + throwCmdError "reaction not allowed - chat item has no content" rs <- withFastStore' $ \db -> getDirectReactions db ct itemSharedMId True checkReactionAllowed rs (SndMessage {msgId}, _) <- sendDirectContactMessage user ct $ XMsgReact itemSharedMId Nothing reaction add @@ -744,47 +760,53 @@ processChatCommand' vr = \case let ci' = CChatItem md ci {reactions} r = ACIReaction SCTDirect SMDSnd (DirectChat ct) $ CIReaction CIDirectSnd ci' createdAt reaction pure $ CRChatItemReaction user add r - _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" + _ -> throwCmdError "reaction not possible - no shared item ID" CTGroup -> - withGroupLock "chatItemReaction" chatId $ - withFastStore (\db -> (,) <$> getGroup db vr user chatId <*> getGroupChatItem db user chatId itemId) >>= \case - (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do + withGroupLock "chatItemReaction" chatId $ do + -- TODO [knocking] check chat item scope? + (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do + g <- getGroupInfo db vr user chatId + (g,) <$> getGroupCIWithReactions db user g itemId + (chatScopeInfo, recipients) <- getGroupRecipients vr user g scope groupKnockingVersion + case ci of + ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} -> do unless (groupFeatureAllowed SGFReactions g) $ - throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) + throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) unless (ciReactionAllowed ci) $ - throwChatError (CECommandError "reaction not allowed - chat item has no content") + throwCmdError "reaction not allowed - chat item has no content" let GroupMember {memberId = itemMemberId} = chatItemMember g ci rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) + SndMessage {msgId} <- sendGroupMessage user g scope recipients (XMsgReact itemSharedMId (Just itemMemberId) reaction add) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt liftIO $ getGroupCIReactions db g itemMemberId itemSharedMId let ci' = CChatItem md ci {reactions} - r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction + r = ACIReaction SCTGroup SMDSnd (GroupChat g chatScopeInfo) $ CIReaction CIGroupSnd ci' createdAt reaction pure $ CRChatItemReaction user add r - _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" - CTLocal -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "invalid reaction" + CTLocal -> throwCmdError "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where checkReactionAllowed rs = do when ((reaction `elem` rs) == add) $ - throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed") + throwCmdError $ "reaction already " <> if add then "added" else "removed" when (add && length rs >= maxMsgReactions) $ - throwChatError (CECommandError "too many reactions") + throwCmdError "too many reactions" APIGetReactionMembers userId groupId itemId reaction -> withUserId userId $ \user -> do memberReactions <- withStore $ \db -> do CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} <- getGroupChatItem db user groupId itemId liftIO $ getReactionMembers db vr user groupId itemSharedMId reaction pure $ CRReactionMembers user memberReactions - APIPlanForwardChatItems (ChatRef fromCType fromChatId) itemIds -> withUser $ \user -> case fromCType of + -- TODO [knocking] forward from scope? + APIPlanForwardChatItems (ChatRef fromCType fromChatId _scope) itemIds -> withUser $ \user -> case fromCType of CTDirect -> planForward user . snd =<< getCommandDirectChatItems user fromChatId itemIds CTGroup -> planForward user . snd =<< getCommandGroupChatItems user fromChatId itemIds CTLocal -> planForward user . snd =<< getCommandLocalChatItems user fromChatId itemIds - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where planForward :: User -> [CChatItem c] -> CM ChatResponse planForward user items = do @@ -825,7 +847,8 @@ processChatCommand' vr = \case MCFile t -> t /= "" MCReport {} -> True MCUnknown {} -> True - APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of + -- TODO [knocking] forward from / to scope + APIForwardChatItems toChat@(ChatRef toCType toChatId toScope) fromChat@(ChatRef fromCType fromChatId _fromScope) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do cmrs <- prepareForward user case L.nonEmpty cmrs of @@ -837,8 +860,9 @@ processChatCommand' vr = \case cmrs <- prepareForward user case L.nonEmpty cmrs of Just cmrs' -> - withGroupLock "forwardChatItem, to group" toChatId $ - sendGroupContentMessages user toChatId False itemTTL cmrs' + withGroupLock "forwardChatItem, to group" toChatId $ do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId + sendGroupContentMessages user gInfo toScope False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -846,34 +870,40 @@ processChatCommand' vr = \case Just cmrs' -> createNoteFolderContentItems user toChatId cmrs' Nothing -> pure $ CRNewChatItems user [] - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where - prepareForward :: User -> CM [ComposeMessageReq] + prepareForward :: User -> CM [ComposedMessageReq] prepareForward user = case fromCType of CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do (ct, items) <- getCommandDirectChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ct ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq ct (CChatItem md ci) (mc', file) = let itemId = chatItemId' ci ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) (toMsgDirection md) (Just fromChatId) (Just itemId)) - in (ComposedMessage file Nothing mc', ciff) + in (composedMessage file mc', ciff, msgContentTexts mc', M.empty) where forwardName :: Contact -> ContactName forwardName Contact {profile = LocalProfile {displayName, localAlias}} | localAlias /= "" = localAlias | otherwise = displayName + -- TODO [knocking] from scope? CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq - ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do + ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq + ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText}) (mc, file) = do let itemId = chatItemId' ci ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) - in (ComposedMessage file Nothing mc', ciff) + -- updates text to reflect current mentioned member names + (mc', _, mentions') = updatedMentionNames mc formattedText mentions + -- only includes mentions when forwarding to the same group + ciMentions = if toChat == fromChat then mentions' else M.empty + -- no need to have mentions in ComposedMessage, they are in ciMentions + in (ComposedMessage file Nothing mc' M.empty, ciff, msgContentTexts mc', ciMentions) where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName @@ -881,12 +911,12 @@ processChatCommand' vr = \case (_, items) <- getCommandLocalChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq (CChatItem _ ci) (mc', file) = let ciff = forwardCIFF ci Nothing - in (ComposedMessage file Nothing mc', ciff) - CTContactRequest -> throwChatError $ CECommandError "not supported" - CTContactConnection -> throwChatError $ CECommandError "not supported" + in (composedMessage file mc', ciff, msgContentTexts mc', M.empty) + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where prepareMsgReq :: CChatItem c -> CM (Maybe (MsgContent, Maybe CryptoFile)) prepareMsgReq (CChatItem _ ci) = forwardMsgContent ci $>>= forwardContent ci @@ -959,7 +989,7 @@ processChatCommand' vr = \case pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId - APIChatRead chatRef@(ChatRef cType chatId) -> withUser $ \_ -> case cType of + APIChatRead chatRef@(ChatRef cType chatId scope) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId ts <- liftIO getCurrentTime @@ -970,11 +1000,14 @@ processChatCommand' vr = \case forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user CTGroup -> do - user <- withFastStore $ \db -> getUserByGroupId db chatId + (user, gInfo) <- withFastStore $ \db -> do + user <- getUserByGroupId db chatId + gInfo <- getGroupInfo db vr user chatId + pure (user, gInfo) ts <- liftIO getCurrentTime timedItems <- withFastStore' $ \db -> do timedItems <- getGroupUnreadTimedItems db user chatId - updateGroupChatItemsRead db user chatId + updateGroupChatItemsRead db user gInfo scope setGroupChatItemsDeleteAt db user chatId timedItems ts forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user @@ -982,33 +1015,42 @@ processChatCommand' vr = \case user <- withFastStore $ \db -> getUserByNoteFolderId db chatId withFastStore' $ \db -> updateLocalChatItemsRead db user chatId ok user - CTContactRequest -> pure $ chatCmdError Nothing "not supported" - CTContactConnection -> pure $ chatCmdError Nothing "not supported" - APIChatItemsRead chatRef@(ChatRef cType chatId) itemIds -> withUser $ \_ -> case cType of + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" + APIChatItemsRead chatRef@(ChatRef cType chatId scope) itemIds -> withUser $ \_ -> case cType of CTDirect -> do - user <- withFastStore $ \db -> getUserByContactId db chatId + (user, ct) <- withFastStore $ \db -> do + user <- getUserByContactId db chatId + ct <- getContact db vr user chatId + pure (user, ct) timedItems <- withFastStore' $ \db -> do timedItems <- updateDirectChatItemsReadList db user chatId itemIds setDirectChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + pure $ CRItemsReadForChat user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do - user <- withFastStore $ \db -> getUserByGroupId db chatId - timedItems <- withFastStore' $ \db -> do - timedItems <- updateGroupChatItemsReadList db user chatId itemIds - setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + (user, gInfo) <- withFastStore $ \db -> do + user <- getUserByGroupId db chatId + gInfo <- getGroupInfo db vr user chatId + pure (user, gInfo) + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + (timedItems, gInfo') <- withFastStore $ \db -> do + (timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo chatScopeInfo itemIds + timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + pure (timedItems', gInfo') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user - CTLocal -> pure $ chatCmdError Nothing "not supported" - CTContactRequest -> pure $ chatCmdError Nothing "not supported" - CTContactConnection -> pure $ chatCmdError Nothing "not supported" - APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of + pure $ CRItemsReadForChat user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) + CTLocal -> throwCmdError "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" + APIChatUnread (ChatRef cType chatId scope) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do ct <- getContact db vr user chatId liftIO $ updateContactUnreadChat db user ct unreadChat ok user - CTGroup -> do + -- TODO [knocking] set support chat as unread? + CTGroup | isNothing scope -> do withFastStore $ \db -> do Group {groupInfo} <- getGroup db vr user chatId liftIO $ updateGroupUnreadChat db user groupInfo unreadChat @@ -1018,16 +1060,15 @@ processChatCommand' vr = \case nf <- getNoteFolder db user chatId liftIO $ updateNoteFolderUnreadChat db user nf unreadChat ok user - _ -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChat cRef@(ChatRef cType chatId) cdm -> withUser $ \user@User {userId} -> case cType of + _ -> throwCmdError "not supported" + APIDeleteChat cRef@(ChatRef cType chatId scope) cdm -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withFastStore $ \db -> getContact db vr user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct withContactLock "deleteChat direct" chatId . procCmd $ case cdm of CDMFull notify -> do - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo sendDelDeleteConns ct notify -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) @@ -1053,71 +1094,48 @@ processChatCommand' vr = \case let doSendDel = contactReady ct && contactActive ct && notify when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db vr userId ct) - deleteAgentConnectionsAsync' user contactConnIds doSendDel + deleteAgentConnectionsAsync' contactConnIds doSendDel CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withFastStore $ \db -> getPendingContactConnection db userId chatId - deleteAgentConnectionAsync user acId + deleteAgentConnectionAsync acId withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn - CTGroup -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user chatId + CTGroup | isNothing scope -> do + Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user chatId let GroupMember {memberRole = membershipMemRole} = membership let isOwner = membershipMemRole == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner - filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo + filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId . procCmd $ do - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo let doSendDel = memberActive membership && isOwner - when doSendDel . void $ sendGroupMessage' user gInfo members XGrpDel + recipients = filter memberCurrentOrPending members + when doSendDel . void $ sendGroupMessage' user gInfo recipients XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () - -- functions below are called in separate transactions to prevent crashes on android - -- (possibly, race condition on integrity check?) - withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members - withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members - withStore' $ \db -> deleteGroup db user gInfo - let contactIds = mapMaybe memberContactId members - (errs1, (errs2, connIds)) <- lift $ second unzip . partitionEithers <$> withStoreBatch (\db -> map (deleteUnusedContact db) contactIds) - let errs = errs1 <> mapMaybe (fmap ChatErrorStore) errs2 - unless (null errs) $ toView $ CRChatErrors (Just user) errs - deleteAgentConnectionsAsync user $ concat connIds + withFastStore' $ \db -> deleteGroupChatItems db user gInfo + withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo + withFastStore' $ \db -> deleteGroupMembers db user gInfo + withFastStore' $ \db -> deleteGroup db user gInfo pure $ CRGroupDeletedUser user gInfo - where - deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId])) - deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do - ct <- getContact db vr user contactId - ifM - ((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct)) - (pure (Nothing, [])) - (getConnections ct) - where - getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId]) - getConnections ct = do - conns <- liftIO $ getContactConnections db vr userId ct - e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just) - pure (e_, map aConnId conns) - CTLocal -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of + _ -> throwCmdError "not supported" + APIClearChat (ChatRef cType chatId scope) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withFastStore $ \db -> getContact db vr user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withFastStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) - CTGroup -> do + CTGroup | isNothing scope -> do gInfo <- withFastStore $ \db -> getGroupInfo db vr user chatId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withFastStore' $ \db -> deleteGroupChatItemsMessages db user gInfo membersToDelete <- withFastStore' $ \db -> getGroupMembersForExpiration db vr user gInfo forM_ membersToDelete $ \m -> withFastStore' $ \db -> deleteGroupMember db user m - pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) + pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo Nothing) CTLocal -> do nf <- withFastStore $ \db -> getNoteFolder db user chatId filesInfo <- withFastStore' $ \db -> getNoteFolderFileInfo db user nf @@ -1125,15 +1143,14 @@ processChatCommand' vr = \case withFastStore' $ \db -> deleteNoteFolderFiles db userId nf withFastStore' $ \db -> deleteNoteFolderCIs db user nf pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "not supported" APIAcceptContact incognito connReqId -> withUser $ \_ -> do userContactLinkId <- withFastStore $ \db -> getUserContactLinkIdByCReq db connReqId withUserContactLock "acceptContact" userContactLinkId $ do (user@User {userId}, cReq) <- withFastStore $ \db -> getContactRequest' db connReqId (ct, conn@Connection {connId}, sqSecured) <- acceptContactRequest user cReq incognito ucl <- withFastStore $ \db -> getUserContactLinkById db userId userContactLinkId - let contactUsed = (\(_, groupId_, _) -> isNothing groupId_) ucl + let contactUsed = (\(_, gLinkInfo_) -> isNothing gLinkInfo_) ucl ct' <- withStore' $ \db -> do deleteContactRequestRec db user cReq updateContactAccepted db user ct contactUsed @@ -1171,9 +1188,9 @@ processChatCommand' vr = \case let call' = Call {contactId, callId, callUUID, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] ok user - else pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFCalls)) + else throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFCalls) SendCallInvitation cName callType -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName processChatCommand $ APISendCallInvitation contactId callType @@ -1186,7 +1203,7 @@ processChatCommand' vr = \case timed_ <- contactCITimed ct updateDirectChatItemView user ct chatItemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId) pure Nothing _ -> throwChatError . CECallState $ callStateTag callState APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> @@ -1276,48 +1293,40 @@ processChatCommand' vr = \case pure user' when (userId == uId') $ chatWriteVar currentUser $ Just (user :: User) {uiThemes} ok user' - APISetChatUIThemes (ChatRef cType chatId) uiThemes -> withUser $ \user -> case cType of + APISetChatUIThemes (ChatRef cType chatId scope) uiThemes -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do ct <- getContact db vr user chatId liftIO $ setContactUIThemes db user ct uiThemes ok user - CTGroup -> do + CTGroup | isNothing scope -> do withFastStore $ \db -> do g <- getGroupInfo db vr user chatId liftIO $ setGroupUIThemes db user g uiThemes ok user - _ -> pure $ chatCmdError (Just user) "not supported" - APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text + _ -> throwCmdError "not supported" APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken APIRegisterToken token mode -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) >> ok_ + APICheckToken token -> withUser $ \_ -> + CRNtfTokenStatus <$> withAgent (`checkNtfToken` token) APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ - APIGetNtfConns nonce encNtfInfo -> withUser $ \user -> do + APIGetNtfConns nonce encNtfInfo -> withUser $ \_ -> do ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo (errs, ntfMsgs) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (getMsgConn db) (L.toList ntfInfos)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRNtfConns ntfMsgs + unless (null errs) $ toView $ CEvtChatErrors errs + pure $ CRNtfConns $ catMaybes ntfMsgs where - getMsgConn :: DB.Connection -> NotificationInfo -> IO NtfConn - getMsgConn db NotificationInfo {ntfConnId, ntfMsgMeta = nMsgMeta} = do + getMsgConn :: DB.Connection -> NotificationInfo -> IO (Maybe NtfConn) + getMsgConn db NotificationInfo {ntfConnId, ntfDbQueueId, ntfMsgMeta = nMsgMeta} = do let agentConnId = AgentConnId ntfConnId - user_ <- getUserByAConnId db agentConnId - connEntity_ <- - pure user_ $>>= \user -> - eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId) - pure $ - NtfConn - { user_, - connEntity_, - -- Decrypted ntf meta of the expected message (the one notification was sent for) - expectedMsg_ = expectedMsgInfo <$> nMsgMeta - } - ApiGetConnNtfMessages connIds -> withUser $ \_ -> do - let acIds = L.map (\(AgentConnId acId) -> acId) connIds - msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds - let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs + mkNtfConn user connEntity = NtfConn {user, agentConnId, agentDbQueueId = ntfDbQueueId, connEntity, expectedMsg_ = expectedMsgInfo <$> nMsgMeta} + getUserByAConnId db agentConnId + $>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId) + APIGetConnNtfMessages connMsgs -> withUser $ \_ -> do + msgs <- lift $ withAgent' (`getConnectionMessages` connMsgs) + let ntfMsgs = L.map receivedMsgInfo msgs pure $ CRConnNtfMessages ntfMsgs GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do srvs <- withFastStore (`getUserServers` user) @@ -1325,9 +1334,9 @@ processChatCommand' vr = \case SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) case L.nonEmpty userServers_ of - Nothing -> throwChatError $ CECommandError "no servers" + Nothing -> throwCmdError "no servers" Just userServers -> case srvs of - [] -> throwChatError $ CECommandError "no servers" + [] -> throwCmdError "no servers" _ -> do srvs' <- mapM aUserServer srvs processChatCommand $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers @@ -1335,7 +1344,7 @@ processChatCommand' vr = \case aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of Just Refl -> pure $ AUS SDBNew $ newUserServer srv - Nothing -> throwChatError $ CECommandError $ "incorrect server protocol: " <> B.unpack (strEncode srv) + Nothing -> throwCmdError $ "incorrect server protocol: " <> B.unpack (strEncode srv) APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> @@ -1375,7 +1384,7 @@ processChatCommand' vr = \case CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do errors <- validateAllUsersServers userId $ L.toList userServers - unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors) + unless (null errors) $ throwCmdError $ "user servers validation error(s): " <> show errors uss <- withFastStore $ \db -> do ts <- liftIO getCurrentTime mapM (setUserServers db user ts) userServers @@ -1409,7 +1418,7 @@ processChatCommand' vr = \case currentTs <- liftIO getCurrentTime acceptConditions db condId opIds currentTs CRServerOperatorConditions <$> getServerOperators db - APISetChatTTL userId (ChatRef cType chatId) newTTL_ -> + APISetChatTTL userId (ChatRef cType chatId scope) newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do (oldTTL_, globalTTL, ttlCount) <- withStore' $ \db -> (,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user @@ -1417,31 +1426,32 @@ processChatCommand' vr = \case oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do lift $ setExpireCIFlag user False - expireChat user globalTTL `catchChatError` (toView . CRChatError (Just user)) + expireChat user globalTTL `catchChatError` eToView lift $ setChatItemsExpiration user globalTTL ttlCount ok user where getSetChatTTL db = case cType of CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ - CTGroup -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + CTGroup | isNothing scope -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ _ -> pure Nothing expireChat user globalTTL = do currentTs <- liftIO getCurrentTime case cType of CTDirect -> expireContactChatItems user vr globalTTL chatId - CTGroup -> + CTGroup | isNothing scope -> let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs in expireGroupChatItems user vr globalTTL createdAtCutoff chatId - _ -> throwChatError $ CECommandError "not supported" + _ -> throwCmdError "not supported" SetChatTTL chatName newTTL -> withUser' $ \user@User {userId} -> do chatRef <- getChatRef user chatName processChatCommand $ APISetChatTTL userId chatRef newTTL GetChatTTL chatName -> withUser' $ \user -> do - ChatRef cType chatId <- getChatRef user chatName + -- TODO [knocking] support scope in CLI apis + ChatRef cType chatId _ <- getChatRef user chatName ttl <- case cType of CTDirect -> withFastStore' (`getDirectChatTTL` chatId) CTGroup -> withFastStore' (`getGroupChatTTL` chatId) - _ -> throwChatError $ CECommandError "not supported" + _ -> throwCmdError "not supported" pure $ CRChatItemTTL user ttl APISetChatItemTTL userId newTTL -> withUserId userId $ \user -> checkStoreNotChanged $ @@ -1472,7 +1482,7 @@ processChatCommand' vr = \case ReconnectServer userId srv -> withUserId userId $ \user -> do lift (withAgent' $ \a -> reconnectSMPServer a (aUserId user) srv) ok_ - APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of + APISetChatSettings (ChatRef cType chatId scope) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withFastStore $ \db -> do ct <- getContact db vr user chatId @@ -1481,21 +1491,21 @@ processChatCommand' vr = \case forM_ (contactConnId ct) $ \connId -> withAgent $ \a -> toggleConnectionNtfs a connId (chatHasNtfs chatSettings) ok user - CTGroup -> do + CTGroup | isNothing scope -> do ms <- withFastStore $ \db -> do Group _ ms <- getGroup db vr user chatId liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` eToView ok user - _ -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withFastStore $ \db -> do liftIO $ updateGroupMemberSettings db user gId gMemberId settings getGroupMember db vr user gId gMemberId let ntfOn = showMessages $ memberSettings m - toggleNtf user m ntfOn + toggleNtf m ntfOn ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact @@ -1535,7 +1545,7 @@ processChatCommand' vr = \case case memberConnId m of Just connId -> do connectionStats <- withAgent (\a -> switchConnectionAsync a "" connId) - pure $ CRGroupMemberSwitchStarted user g m connectionStats + pure $ CEvtGroupMemberSwitchStarted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APIAbortSwitchContact contactId -> withUser $ \user -> do ct <- withFastStore $ \db -> getContact db vr user contactId @@ -1549,7 +1559,7 @@ processChatCommand' vr = \case case memberConnId m of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId - pure $ CRGroupMemberSwitchAborted user g m connectionStats + pure $ CEvtGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APISyncContactRatchet contactId force -> withUser $ \user -> withContactLock "syncContactRatchet" contactId $ do ct <- withFastStore $ \db -> getContact db vr user contactId @@ -1564,8 +1574,9 @@ processChatCommand' vr = \case case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force - createInternalChatItem user (CDGroupSnd g) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m) Nothing - pure $ CRGroupMemberRatchetSyncStarted user g m cStats + (g', m', scopeInfo) <- mkGroupChatScope g m + createInternalChatItem user (CDGroupSnd g' scopeInfo) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m') Nothing + pure $ CRGroupMemberRatchetSyncStarted user g' m' cStats _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId @@ -1651,16 +1662,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 @@ -1678,9 +1691,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 @@ -1701,19 +1714,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 - deleteAgentConnectionAsync user (aConnId' conn) + createDirectConnection db newUser agConnId ccLink' ConnNew Nothing subMode initialChatVersion PQSupportOn + deleteAgentConnectionAsync (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 @@ -1732,11 +1747,12 @@ processChatCommand' vr = \case -- retrying join after error pcc <- withFastStore $ \db -> getPendingContactConnection db userId connId joinPreparedConn (aConnId conn) pcc dm - Just ent -> throwChatError $ CECommandError $ "connection exists: " <> show (connEntityInfo ent) + Just ent -> throwCmdError $ "connection exists: " <> show (connEntityInfo ent) 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 @@ -1746,47 +1762,44 @@ 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 - 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) - DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) cdm - ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect + when (isJust activeConn) $ throwCmdError "contact already has connection" + 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 -> throwCmdError "no address in contact profile" + 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 + conn <- withFastStore $ \db -> getUserAddressConnection db vr user withChatLock "deleteMyAddress" $ do - deleteAgentConnectionsAsync user $ map aConnId conns + deleteAgentConnectionAsync $ aConnId conn withFastStore' (`deleteUserAddress` user) let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} r <- updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing @@ -1800,18 +1813,30 @@ processChatCommand' vr = \case CRUserContactLink user <$> withFastStore (`getUserAddress` user) ShowMyAddress -> withUser' $ \User {userId} -> processChatCommand $ APIShowMyAddress userId + APIAddMyAddressShortLink userId -> withUserId' userId $ \user -> do + (ucl@UserContactLink {connLinkContact = CCLink connFullLink sLnk_}, conn) <- + withFastStore $ \db -> (,) <$> getUserAddress db user <*> getUserAddressConnection db vr user + when (isJust sLnk_) $ throwCmdError "address already has short link" + sLnk <- shortenShortLink' =<< withAgent (\a -> setContactShortLink a (aConnId conn) "" Nothing) + case entityId conn of + Just uclId -> do + withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk + let ucl' = (ucl :: UserContactLink) {connLinkContact = CCLink connFullLink (Just sLnk)} + pure $ CRUserContactLink user ucl' + Nothing -> throwChatError $ CEException "no user contact link id" APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do 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 APIAddressAutoAccept userId autoAccept_ -> withUserId userId $ \user -> do forM_ autoAccept_ $ \AutoAccept {businessAddress, acceptIncognito} -> - when (businessAddress && acceptIncognito) $ throwChatError $ CECommandError "requests to business address cannot be accepted incognito" + when (businessAddress && acceptIncognito) $ throwCmdError "requests to business address cannot be accepted incognito" contactLink <- withFastStore (\db -> updateUserAddressAutoAccept db user autoAccept_) pure $ CRUserContactLinkUpdated user contactLink AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> @@ -1826,25 +1851,25 @@ processChatCommand' vr = \case contactId <- withFastStore $ \db -> getContactIdByName db user fromContactName forwardedItemId <- withFastStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId) (forwardedItemId :| []) Nothing + processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user fromGroupName forwardedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId) (forwardedItemId :| []) Nothing + processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) forwardedItemId <- withFastStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId) (forwardedItemId :| []) Nothing - SendMessage (ChatName cType name) msg -> withUser $ \user -> do + processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing + SendMessage sendName msg -> withUser $ \user -> do let mc = MCText msg - case cType of - CTDirect -> + case sendName of + SNDirect name -> withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do - let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + let sendRef = SRDirect ctId + processChatCommand $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] Left _ -> withFastStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case Right [(gInfo, member)] -> do @@ -1855,16 +1880,18 @@ processChatCommand' vr = \case throwChatError $ CEContactNotFound name (Just suspectedMember) _ -> throwChatError $ CEContactNotFound name Nothing - CTGroup -> do - gId <- withFastStore $ \db -> getGroupIdByName db user name - let chatRef = ChatRef CTGroup gId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) - CTLocal - | name == "" -> do - folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand $ APICreateChatItems folderId (ComposedMessage Nothing Nothing mc :| []) - | otherwise -> throwChatError $ CECommandError "not supported" - _ -> throwChatError $ CECommandError "not supported" + SNGroup name scope_ -> do + (gId, cScope_, mentions) <- withFastStore $ \db -> do + gId <- getGroupIdByName db user name + cScope_ <- + forM scope_ $ \(GSNMemberSupport mName_) -> + GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ + (gId,cScope_,) <$> liftIO (getMessageMentions db user gId msg) + let sendRef = SRGroup gId cScope_ + processChatCommand $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] + SNLocal -> do + folderId <- withFastStore (`getUserNoteFolderId` user) + processChatCommand $ APICreateChatItems folderId [composedMessage Nothing mc] SendMemberContactMessage gName mName msg -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName m <- withFastStore $ \db -> getGroupMember db vr user gId mId @@ -1872,21 +1899,22 @@ processChatCommand' vr = \case case memberContactId m of Nothing -> do g <- withFastStore $ \db -> getGroupInfo db vr user gId - unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwChatError $ CECommandError "direct messages not allowed" - toView $ CRNoMemberContactCreating user g m + unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwCmdError "direct messages not allowed" + toView $ CEvtNoMemberContactCreating user g m processChatCommand (APICreateMemberContact gId mId) >>= \case - cr@(CRNewMemberContact _ Contact {contactId} _ _) -> do - toView cr + CRNewMemberContact _ ct@Contact {contactId} _ _ -> do + toViewTE $ TENewMemberContact user ct g m processChatCommand $ APISendMemberContactInvitation contactId (Just mc) cr -> pure cr Just ctId -> do - let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + let sendRef = SRDirect ctId + processChatCommand $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] SendLiveMessage chatName msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName - let mc = MCText msg - processChatCommand $ APISendMessages chatRef True Nothing (ComposedMessage Nothing Nothing mc :| []) - SendMessageBroadcast msg -> withUser $ \user -> do + (chatRef, mentions) <- getChatRefAndMentions user chatName msg + withSendRef chatRef $ \sendRef -> do + let mc = MCText msg + processChatCommand $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] + SendMessageBroadcast mc -> withUser $ \user -> do contacts <- withFastStore' $ \db -> getUserContacts db vr user withChatLock "sendMessageBroadcast" . procCmd $ do let ctConns_ = L.nonEmpty $ foldr addContactConn [] contacts @@ -1896,6 +1924,12 @@ processChatCommand' vr = \case pure CRBroadcastSent {user, msgContent = mc, successes = 0, failures = 0, timestamp} Just (ctConns :: NonEmpty (Contact, Connection)) -> do let idsEvts = L.map ctSndEvent ctConns + -- TODO Broadcast rework + -- In createNewSndMessage and encodeChatMessage we could use Nothing for sharedMsgId, + -- then we could reuse message body across broadcast. + -- Encoding different sharedMsgId and reusing body is meaningless as referencing will not work anyway. + -- As an improvement, single message record with its sharedMsgId could be created for new "broadcast" entity. + -- Then all recipients could refer to broadcast message using same sharedMsgId. sndMsgs <- lift $ createSndMessages idsEvts let msgReqs_ :: NonEmpty (Either ChatError ChatMsgReq) = L.zipWith (fmap . ctMsgReq) ctConns sndMsgs (errs, ctSndMsgs :: [(Contact, SndMessage)]) <- @@ -1904,7 +1938,6 @@ processChatCommand' vr = \case lift . void $ withStoreBatch' $ \db -> map (createCI db user timestamp) ctSndMsgs pure CRBroadcastSent {user, msgContent = mc, successes = length ctSndMsgs, failures = length errs, timestamp} where - mc = MCText msg addContactConn :: Contact -> [(Contact, Connection)] -> [(Contact, Connection)] addContactConn ct ctConns = case contactSendConn_ ct of Right conn | directOrUsed ct -> (ct, conn) : ctConns @@ -1912,9 +1945,7 @@ processChatCommand' vr = \case ctSndEvent :: (Contact, Connection) -> (ConnOrGroupId, ChatMsgEvent 'Json) ctSndEvent (_, Connection {connId}) = (ConnectionId connId, XMsgNew $ MCSimple (extMsgContent mc Nothing)) ctMsgReq :: (Contact, Connection) -> SndMessage -> ChatMsgReq - ctMsgReq (_, conn) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgNew_}, msgBody, [msgId]) - zipWith3' :: (a -> b -> c -> d) -> NonEmpty a -> NonEmpty b -> NonEmpty c -> NonEmpty d - zipWith3' f ~(x :| xs) ~(y :| ys) ~(z :| zs) = f x y z :| zipWith3 f xs ys zs + ctMsgReq (_, conn) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgNew_}, (vrValue msgBody, [msgId])) combineResults :: (Contact, Connection) -> Either ChatError SndMessage -> Either ChatError ([Int64], PQEncryption) -> Either ChatError (Contact, SndMessage) combineResults (ct, _) (Right msg') (Right _) = Right (ct, msg') combineResults _ (Left e) _ = Left e @@ -1926,7 +1957,7 @@ processChatCommand' vr = \case contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg let mc = MCText msg - processChatCommand $ APISendMessages (ChatRef CTDirect contactId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| []) + processChatCommand $ APISendMessages (SRDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg @@ -1936,14 +1967,14 @@ processChatCommand' vr = \case deletedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId (Just mName) deletedMsg processChatCommand $ APIDeleteMemberChatItem gId (deletedItemId :| []) EditMessage chatName editedMsg msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg editedItemId <- getSentChatItemIdByText user chatRef editedMsg let mc = MCText msg - processChatCommand $ APIUpdateChatItem chatRef editedItemId False mc + processChatCommand $ APIUpdateChatItem chatRef editedItemId False $ UpdatedMessage mc mentions UpdateLiveMessage chatName chatItemId live msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg let mc = MCText msg - processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc + processChatCommand $ APIUpdateChatItem chatRef chatItemId live $ UpdatedMessage mc mentions ReactToMessage add reaction chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg @@ -1954,7 +1985,7 @@ processChatCommand' vr = \case -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing gInfo <- withFastStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile - let cd = CDGroupSnd gInfo + let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo pure $ CRGroupCreated user gInfo @@ -1976,7 +2007,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 @@ -2019,115 +2050,328 @@ processChatCommand' vr = \case updateGroupMemberStatus db userId fromMember GSMemInvited updateGroupMemberStatus db userId membership GSMemInvited throwError e - updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` (toView . CRChatError (Just user)) + updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` eToView pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct - APIMemberRole groupId memberId memRole -> withUser $ \user -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId - if memberId == groupMemberId' membership - then changeMemberRole user gInfo members membership $ SGEUserRole memRole - else case find ((== memberId) . groupMemberId') members of - Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole - _ -> throwChatError CEGroupMemberNotFound + APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do + (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId + assertUserGroupRole gInfo GRModerator + case memberStatus m of + GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve + let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo + case memberConn m of + Just mConn -> + case memberAdmission >>= review of + Just MCAll -> do + introduceToModerators vr user gInfo m + withFastStore' $ \db -> updateGroupMemberStatus db userId m GSMemPendingReview + let m' = m {memberStatus = GSMemPendingReview} + pure $ CRMemberAccepted user gInfo m' + Nothing -> do + let msg = XGrpLinkAcpt GAAccepted role (memberId' m) + void $ sendDirectMemberMessage mConn msg groupId + introduceToRemaining vr user gInfo m {memberRole = role} + when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m + (m', gInfo') <- withFastStore' $ \db -> do + m' <- updateGroupMemberAccepted db user m GSMemConnected role + gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' + pure (m', gInfo') + -- create item in both scopes + createInternalChatItem user (CDGroupRcv gInfo' Nothing m') (CIRcvGroupEvent RGEMemberConnected) Nothing + let scopeInfo = Just GCSIMemberSupport {groupMember_ = Just m'} + gEvent = SGEMemberAccepted gmId (fromLocalProfile $ memberProfile m') + createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent gEvent) Nothing + pure $ CRMemberAccepted user gInfo' m' + Nothing -> throwChatError CEGroupMemberNotActive + GSMemPendingReview -> do + let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter memberCurrent modMs + msg = XGrpLinkAcpt GAAccepted role (memberId' m) + void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg + when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ + forM_ (memberConn m) $ \mConn -> do + let msg2 = XMsgNew $ MCSimple $ extMsgContent (MCText acceptedToGroupMessage) Nothing + void $ sendDirectMemberMessage mConn msg2 groupId + when (memberCategory m == GCInviteeMember) $ do + introduceToRemaining vr user gInfo m {memberRole = role} + when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m + (m', gInfo') <- withFastStore' $ \db -> do + m' <- updateGroupMemberAccepted db user m newMemberStatus role + gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' + pure (m', gInfo') + -- create item in both scopes + createInternalChatItem user (CDGroupRcv gInfo' Nothing m') (CIRcvGroupEvent RGEMemberConnected) Nothing + let scopeInfo = Just GCSIMemberSupport {groupMember_ = Just m'} + gEvent = SGEMemberAccepted gmId (fromLocalProfile $ memberProfile m') + createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent gEvent) Nothing + pure $ CRMemberAccepted user gInfo' m' + where + newMemberStatus = case memberConn m of + Just c | connReady c -> GSMemConnected + _ -> GSMemAnnounced + _ -> throwCmdError "member should be pending approval and invitee, or pending review and not invitee" + APIDeleteMemberSupportChat groupId gmId -> withUser $ \user -> do + (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId + when (isNothing $ supportChat m) $ throwCmdError "member has no support chat" + when (memberPending m) $ throwCmdError "member is pending" + (gInfo', m') <- withFastStore' $ \db -> deleteGroupMemberSupportChat db user gInfo m + pure $ CRMemberSupportChatDeleted user gInfo' m' + APIMembersRole groupId memberIds newRole -> withUser $ \user -> + withGroupLock "memberRole" groupId . procCmd $ do + g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + when (selfSelected gInfo) $ throwCmdError "can't change role for self" + let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members + when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ + throwCmdError "can't change role of multiple members when admins selected, or new role is admin" + when anyPending $ throwCmdError "can't change role of members pending approval" + assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) + (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems + (errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems + unless (null acis) $ toView $ CEvtNewChatItems user acis + let errs = errs1 <> errs2 + unless (null errs) $ toView $ CEvtChatErrors errs + pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed where - changeMemberRole user gInfo members m gEvent = do - let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m - assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole]) - withGroupLock "memberRole" groupId . procCmd $ do - unless (mRole == memRole) $ do - withFastStore' $ \db -> updateGroupMemberRole db user m memRole - case mStatus of - GSMemInvited -> do - withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case - (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq - _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} - APIBlockMemberForAll groupId memberId blocked -> withUser $ \user -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId - when (memberId == groupMemberId' membership) $ throwChatError $ CECommandError "can't block/unblock self" - case splitMember memberId members of - Nothing -> throwChatError $ CEException "expected to find a single blocked member" - Just (bm, remainingMembers) -> do - let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm - -- TODO GRModerator when most users migrate - assertUserGroupRole gInfo $ max GRAdmin bmRole - when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" - withGroupLock "blockForAll" groupId . procCmd $ do - let mrs = if blocked then MRSBlocked else MRSUnrestricted - event = XGrpMemRestrict bmMemberId MemberRestrictions {restriction = mrs} - msg <- sendGroupMessage' user gInfo remainingMembers event - let ciContent = CISndGroupEvent $ SGEMemberBlocked memberId (fromLocalProfile bmp) blocked - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - bm' <- withFastStore $ \db -> do - liftIO $ updateGroupMemberBlocked db user groupId memberId mrs - getGroupMember db vr user groupId memberId - toggleNtf user bm' (not blocked) - pure CRMemberBlockedForAllUser {user, groupInfo = gInfo, member = bm', blocked} + selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) + selectMembers = foldr' addMember ([], [], [], GRObserver, False, False) + where + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + anyPending' = anyPending || memberPending m + in + if + | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending') + | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending') + | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending') + | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending) + changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) + changeRoleInvitedMems user gInfo memsToChange = do + -- not batched, as we need to send different invitations to different connections anyway + mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchChatError` (pure . Left) + pure $ partitionEithers mems_ + where + changeRole :: GroupMember -> CM GroupMember + changeRole m@GroupMember {groupMemberId, memberContactId, localDisplayName = cName} = do + withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case + (Just ct, Just cReq) -> do + sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq + withFastStore' $ \db -> updateGroupMemberRole db user m newRole + pure (m :: GroupMember) {memberRole = newRole} + _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName + changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of + Nothing -> pure ([], [], []) + Just memsToChange' -> do + let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' + recipients = filter memberCurrent members + (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing recipients events + let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False + when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" + (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ + pure (errs, changed, acis) + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberRole groupMemberId (fromLocalProfile memberProfile) newRole + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + updMember db m = do + updateGroupMemberRole db user m newRole + pure (m :: GroupMember) {memberRole = newRole} + APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> + withGroupLock "blockForAll" groupId . procCmd $ do + Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" + let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members + when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && anyAdmin) $ throwCmdError "can't block/unblock multiple members when admins selected" + when anyPending $ throwCmdError "can't block/unblock members pending approval" + assertUserGroupRole gInfo $ max GRModerator maxRole + blockMembers user gInfo blockMems remainingMems where - splitMember mId ms = case break ((== mId) . groupMemberId') ms of - (_, []) -> Nothing - (ms1, bm : ms2) -> Just (bm, ms1 <> ms2) - APIRemoveMember groupId memberId -> withUser $ \user -> do - Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId - case find ((== memberId) . groupMemberId') members of - Nothing -> throwChatError CEGroupMemberNotFound - Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do - assertUserGroupRole gInfo $ max GRAdmin mRole - withGroupLock "removeMember" groupId . procCmd $ do - case mStatus of - GSMemInvited -> do - deleteMemberConnection user m - withFastStore' $ \db -> deleteGroupMember db user m - _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - deleteMemberConnection' user m True - -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user m - pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} + selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) + selectMembers = foldr' addMember ([], [], GRObserver, False, False) + where + addMember m@GroupMember {groupMemberId, memberRole} (block, remaining, maxRole, anyAdmin, anyPending) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + anyPending' = anyPending || memberPending m + in (m : block, remaining, maxRole', anyAdmin', anyPending') + | otherwise = (block, m : remaining, maxRole, anyAdmin, anyPending) + blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse + blockMembers user gInfo blockMems remainingMems = case L.nonEmpty blockMems of + Nothing -> throwCmdError "no members to block/unblock" + Just blockMems' -> do + let mrs = if blockFlag then MRSBlocked else MRSUnrestricted + events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' + recipients = filter memberCurrent remainingMems + (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients events + let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False + when (length cis_ /= length blockMems) $ logError "blockMembers: blockMems and cis_ length mismatch" + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ + unless (null acis) $ toView $ CEvtNewChatItems user acis + (errs, blocked) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updateGroupMemberBlocked db user gInfo mrs) blockMems) + unless (null errs) $ toView $ CEvtChatErrors errs + -- TODO not batched - requires agent batch api + forM_ blocked $ \m -> toggleNtf m (not blockFlag) + pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag} + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> + withGroupLock "removeMembers" groupId . procCmd $ do + Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers members + memCount = S.size groupMemberIds + when (count /= memCount) $ throwChatError CEGroupMemberNotFound + when (memCount > 1 && anyAdmin) $ throwCmdError "can't remove multiple members when admins selected" + assertUserGroupRole gInfo $ max GRAdmin maxRole + (errs1, deleted1) <- deleteInvitedMems user invitedMems + let recipients = filter memberCurrent members + (errs2, deleted2, acis2) <- deleteMemsSend user gInfo Nothing recipients currentMems + (errs3, deleted3, acis3) <- + foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], []) pendingApprvMems + let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members + (errs4, deleted4, acis4) <- + foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], []) pendingRvwMems + let acis = acis2 <> acis3 <> acis4 + errs = errs1 <> errs2 <> errs3 <> errs4 + deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 + -- Read group info with updated membersRequireAttention + gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId + let acis' = map (updateACIGroupInfo gInfo') acis + unless (null acis') $ toView $ CEvtNewChatItems user acis' + unless (null errs) $ toView $ CEvtChatErrors errs + when withMessages $ deleteMessages user gInfo' deleted + pure $ CRUserDeletedMembers user gInfo' deleted withMessages -- same order is not guaranteed + where + selectMembers :: [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldl' addMember (0, [], [], [], [], GRObserver, False) + where + addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole} + | groupMemberId `S.member` groupMemberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + n' = n + 1 + in case memberStatus of + GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin') + GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin') + GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin') + _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin') + | otherwise = acc + deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) + deleteInvitedMems user memsToDelete = do + deleteMembersConnections user memsToDelete + lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) + where + delMember db m = do + deleteGroupMember db user m + pure m {memberStatus = GSMemRemoved} + deletePendingMember :: ([ChatError], [GroupMember], [AChatItem]) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem]) + deletePendingMember (accErrs, accDeleted, accACIs) user gInfo recipients m = do + (m', scopeInfo) <- mkMemberSupportChatInfo m + (errs, deleted, acis) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] + pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs) + deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + deleteMemsSend user gInfo chatScopeInfo recipients memsToDelete = case L.nonEmpty memsToDelete of + Nothing -> pure ([], [], []) + Just memsToDelete' -> do + let chatScope = toChatScope <$> chatScopeInfo + events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' + (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope recipients events + let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData Nothing False + when (length cis_ /= length memsToDelete) $ logError "deleteCurrentMems: memsToDelete and cis_ length mismatch" + deleteMembersConnections' user memsToDelete True + (errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) $ rights cis_ + pure (errs, deleted, acis) + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + delMember db m = do + -- We're in a function used in batch member deletion, and since we're passing same gInfo for each member, + -- voided result (updated group info) may have incorrect state of membersRequireAttention. + -- To avoid complicating code by chaining group info updates, + -- instead we re-read it once after deleting all members before response. + void $ deleteOrUpdateMemberRecordIO db user gInfo m + pure m {memberStatus = GSMemRemoved} + deleteMessages user gInfo@GroupInfo {membership} ms + | groupFeatureMemberAllowed SGFFullDelete membership gInfo = deleteGroupMembersCIs user gInfo ms membership + | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId . procCmd $ do cancelFilesInProgress user filesInfo - msg <- sendGroupMessage' user gInfo members XGrpLeave - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] + let recipients = filter memberCurrentOrPending members + msg <- sendGroupMessage' user gInfo recipients XGrpLeave + (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo + ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] -- TODO delete direct connections that were unused - deleteGroupLinkIfExists user gInfo + deleteGroupLinkIfExists user gInfo' -- member records are not deleted to keep history deleteMembersConnections' user members True withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft - pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} + pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> withUser $ \user -> CRGroupMembers user <$> withFastStore (\db -> getGroup db vr user groupId) + -- -- validate: prohibit to delete/archive if member is pending (has to communicate approval or rejection) + -- APIDeleteGroupConversations groupId _gcId -> withUser $ \user -> do + -- _gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- ok_ -- CRGroupConversationsArchived + -- APIArchiveGroupConversations groupId _gcId -> withUser $ \user -> do + -- _gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- ok_ -- CRGroupConversationsDeleted AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIJoinGroup groupId enableNtfs - MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole - BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMemberForAll gId gMemberId blocked - RemoveMember gName gMemberName -> withMemberName gName gMemberName APIRemoveMember + AcceptMember gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIAcceptMember gId gMemberId memRole + MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMembersRole gId [gMemberId] memRole + BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMembersForAll gId [gMemberId] blocked + RemoveMembers gName gMemberNames withMessages -> withUser $ \user -> do + (gId, gMemberIds) <- withStore $ \db -> do + gId <- getGroupIdByName db user gName + gMemberIds <- S.fromList <$> mapM (getGroupMemberIdByName db user gId) (S.toList gMemberNames) + pure (gId, gMemberIds) + processChatCommand $ APIRemoveMembers gId gMemberIds withMessages LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APILeaveGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) (CDMFull True) + processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) ClearGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIClearChat (ChatRef CTGroup groupId) + processChatCommand $ APIClearChat (ChatRef CTGroup groupId Nothing) ListMembers gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId + ListMemberSupportChats gName -> withUser $ \user -> do + groupId <- withFastStore $ \db -> getGroupIdByName db user gName + (Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + let memberSupportChats = filter (isJust . supportChat) members + pure $ CRMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> CRGroupsList user <$> withFastStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do @@ -2144,19 +2388,21 @@ 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 + (groupLinkId, groupLink, _, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo assertUserGroupRole gInfo GRAdmin when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' when (mRole' /= mRole) $ withFastStore' $ \db -> setGroupLinkMemberRole db user groupLinkId mRole' @@ -2167,19 +2413,31 @@ processChatCommand' vr = \case pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId - (_, groupLink, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo + (_, groupLink, _, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo groupLink mRole + APIAddGroupShortLink groupId -> withUser $ \user -> do + (gInfo, (uclId, _gLink@(CCLink connFullLink sLnk_), gLinkId, mRole), conn) <- withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + gLink <- getGroupLink db user gInfo + conn <- getGroupLinkConnection db vr user gInfo + pure (gInfo, gLink, conn) + when (isJust sLnk_) $ throwCmdError "group link already has short link" + let crClientData = encodeJSON $ CRDataGroup gLinkId + sLnk <- shortenShortLink' =<< toShortGroupLink <$> withAgent (\a -> setContactShortLink a (aConnId conn) "" (Just crClientData)) + withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk + let groupLink' = CCLink connFullLink (Just sLnk) + pure $ CRGroupLink user gInfo groupLink' mRole APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor - unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwChatError $ CECommandError "direct messages not allowed" + unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwCmdError "direct messages not allowed" case memberConn m of Just mConn@Connection {peerChatVRange} -> do unless (maxVersion peerChatVRange >= groupDirectInvVersion) $ throwChatError CEPeerChatVRangeIncompatible - when (isJust $ memberContactId m) $ throwChatError $ CECommandError "member contact already exists" + when (isJust $ memberContactId m) $ throwCmdError "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? @@ -2188,21 +2446,22 @@ processChatCommand' vr = \case _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do (g@GroupInfo {groupId}, m, ct, cReq) <- withFastStore $ \db -> getMemberContact db vr user contactId - when (contactGrpInvSent ct) $ throwChatError $ CECommandError "x.grp.direct.inv already sent" + when (contactGrpInvSent ct) $ throwCmdError "x.grp.direct.inv already sent" case memberConn m of Just mConn -> do - let msg = XGrpDirectInv cReq msgContent_ + -- TODO [knocking] send in correct scope - modiy API + let msg = XGrpDirectInv cReq msgContent_ Nothing (sndMsg, _, _) <- sendDirectMemberMessage mConn msg groupId withFastStore' $ \db -> setContactGrpInvSent db ct True let ct' = ct {contactGrpInvSent = True} forM_ msgContent_ $ \mc -> do ci <- saveSndChatItem user (CDDirectSnd ct') sndMsg (CISndMsgContent mc) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] + toView $ CEvtNewChatItems 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 @@ -2213,17 +2472,20 @@ processChatCommand' vr = \case groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIGetGroupLink groupId SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do - groupId <- withFastStore $ \db -> getGroupIdByName db user gName - quotedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId cName quotedMsg + (groupId, quotedItemId, mentions) <- + withFastStore $ \db -> do + gId <- getGroupIdByName db user gName + qiId <- getGroupChatItemIdByText db user gId cName quotedMsg + (gId, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand $ APISendMessages (ChatRef CTGroup groupId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| []) + processChatCommand $ APISendMessages (SRGroup groupId Nothing) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand $ APIClearChat (ChatRef CTLocal folderId) + processChatCommand $ APIClearChat (ChatRef CTLocal folderId Nothing) LastChats count_ -> withUser' $ \user -> do let count = fromMaybe 5000 count_ (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user False (PTLast count) clqNoFilters) - unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName @@ -2256,19 +2518,20 @@ processChatCommand' vr = \case SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName case chatRef of - ChatRef CTLocal folderId -> processChatCommand $ APICreateChatItems folderId (ComposedMessage (Just f) Nothing (MCFile "") :| []) - _ -> processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCFile "") :| []) + ChatRef CTLocal folderId _ -> processChatCommand $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] + _ -> withSendRef chatRef $ \sendRef -> processChatCommand $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName - filePath <- lift $ toFSFilePath fPath - unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} - fileSize <- getFileSize filePath - unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} - -- TODO include file description for preview - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) :| []) + withSendRef chatRef $ \sendRef -> do + filePath <- lift $ toFSFilePath fPath + unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} + fileSize <- getFileSize filePath + unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} + -- TODO include file description for preview + processChatCommand $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage - SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" + SendFileDescription _chatName _f -> throwCmdError "TODO" -- TODO to use priority transactions we need a parameter that differentiates manual and automatic acceptance ReceiveFile fileId userApprovedRelays encrypted_ rcvInline_ filePath_ -> withUser $ \_ -> withFileLock "receiveFile" fileId . procCmd $ do @@ -2291,18 +2554,22 @@ processChatCommand' vr = \case throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do fileAgentConnIds <- cancelSndFile user ftm fts True - deleteAgentConnectionsAsync user fileAgentConnIds - withFastStore (\db -> liftIO $ lookupChatRefByFileId db user fileId) >>= \case - Nothing -> pure () - Just (ChatRef CTDirect contactId) -> do + deleteAgentConnectionsAsync fileAgentConnIds + cref_ <- withFastStore' $ \db -> lookupChatRefByFileId db user fileId + aci_ <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId + case (cref_, aci_) of + (Nothing, _) -> + pure $ CRSndFileCancelled user Nothing ftm fts + (Just (ChatRef CTDirect contactId _), Just aci) -> do (contact, sharedMsgId) <- withFastStore $ \db -> (,) <$> getContact db vr user contactId <*> getSharedMsgIdByFileId db userId fileId void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId - Just (ChatRef CTGroup groupId) -> do - (Group gInfo ms, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getSharedMsgIdByFileId db userId fileId - void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId - Just _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" - ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId - pure $ CRSndFileCancelled user ci ftm fts + pure $ CRSndFileCancelled user (Just aci) ftm fts + (Just (ChatRef CTGroup groupId scope), Just aci) -> do + (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getSharedMsgIdByFileId db userId fileId + (_chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion + void . sendGroupMessage user gInfo scope recipients $ XFileCancel sharedMsgId + pure $ CRSndFileCancelled user (Just aci) ftm fts + (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} = s == FSCancelled || (s == FSComplete && isNothing xftpSndFile) @@ -2311,7 +2578,7 @@ processChatCommand' vr = \case | rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> case xftpRcvFile of Nothing -> do - cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ftr >>= mapM_ deleteAgentConnectionAsync ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId pure $ CRRcvFileCancelled user ci ftr Just XFTPRcvFile {agentRcvFileId} -> do @@ -2329,7 +2596,7 @@ processChatCommand' vr = \case pure $ CRFileTransferStatus user fileStatus Just ci@(AChatItem _ _ _ ChatItem {file}) -> case file of Just CIFile {fileProtocol = FPLocal} -> - throwChatError $ CECommandError "not supported for local files" + throwCmdError "not supported for local files" Just CIFile {fileProtocol = FPXFTP} -> pure $ CRFileTransferStatusXFTP user ci _ -> do @@ -2356,6 +2623,11 @@ processChatCommand' vr = \case SetGroupFeatureRole (AGFR f) gName enabled role -> updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreferenceRole f enabled role $ groupPreferences p} + SetGroupMemberAdmissionReview gName reviewAdmissionApplication -> + updateGroupProfileByName gName $ \p@GroupProfile {memberAdmission} -> + case memberAdmission of + Nothing -> p {memberAdmission = Just (emptyGroupMemberAdmission :: GroupMemberAdmission) {review = reviewAdmissionApplication}} + Just ma -> p {memberAdmission = Just (ma :: GroupMemberAdmission) {review = reviewAdmissionApplication}} SetUserTimedMessages onOff -> withUser $ \user@User {profile} -> do let allowed = if onOff then FAYes else FANo pref = TimedMessagesPreference allowed Nothing @@ -2406,7 +2678,7 @@ processChatCommand' vr = \case ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( let versionInfo = coreVersionInfo "" - chatMigrations <- map upMigration <$> withFastStore' Migrations.getCurrent + chatMigrations <- map upMigration <$> withFastStore' getCurrentMigrations agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do @@ -2459,33 +2731,31 @@ processChatCommand' vr = \case GetAgentSubsDetails -> lift $ CRAgentSubsDetails <$> withAgent' getAgentSubscriptions GetAgentQueuesInfo -> lift $ CRAgentQueuesInfo <$> withAgent' getAgentQueuesInfo -- CustomChatCommand is unsupported, it can be processed in preCmdHook - -- in a modified CLI app or core - the hook should return Either ChatResponse ChatCommand - CustomChatCommand _cmd -> withUser $ \user -> pure $ chatCmdError (Just user) "not supported" + -- in a modified CLI app or core - the hook should return Either (Either ChatError ChatResponse) ChatCommand, + -- where Left means command result, and Right – some other command to be processed by this function. + CustomChatCommand _cmd -> withUser $ \_ -> throwCmdError "not supported" where - -- below code would make command responses asynchronous where they can be slow - -- in View.hs `r'` should be defined as `id` in this case - -- procCmd :: m ChatResponse -> m ChatResponse - -- procCmd action = do - -- ChatController {chatLock = l, smpAgent = a, outputQ = q, random = gVar} <- ask - -- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 - -- void . forkIO $ - -- withAgentLock a . withLock l name $ - -- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchChatError` (pure . CRChatError)) - -- pure $ CRCmdAccepted corrId - -- use function below to make commands "synchronous" procCmd :: CM ChatResponse -> CM ChatResponse procCmd = id + {-# INLINE procCmd #-} ok_ = pure $ CRCmdOk Nothing ok = pure . CRCmdOk . Just getChatRef :: User -> ChatName -> CM ChatRef - getChatRef user (ChatName cType name) = - ChatRef cType <$> case cType of + getChatRef user (ChatName cType name) = do + chatId <- case cType of CTDirect -> withFastStore $ \db -> getContactIdByName db user name CTGroup -> withFastStore $ \db -> getGroupIdByName db user name CTLocal | name == "" -> withFastStore (`getUserNoteFolderId` user) - | otherwise -> throwChatError $ CECommandError "not supported" - _ -> throwChatError $ CECommandError "not supported" + | otherwise -> throwCmdError "not supported" + _ -> throwCmdError "not supported" + pure $ ChatRef cType chatId Nothing + getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) + getChatRefAndMentions user cName msg = do + chatRef@(ChatRef cType chatId _) <- getChatRef user cName + (chatRef,) <$> case cType of + CTGroup -> withFastStore' $ \db -> getMessageMentions db user chatId msg + _ -> pure [] #if !defined(dbPostgres) checkChatStopped :: CM ChatResponse -> CM ChatResponse checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) @@ -2517,19 +2787,19 @@ processChatCommand' vr = \case withFastStore' $ \db -> setConnectionVerified db user connId Nothing pure $ CRConnectionVerified user False code' getSentChatItemIdByText :: User -> ChatRef -> Text -> CM Int64 - getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of + getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId _scope) msg = case cType of CTDirect -> withFastStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd msg CTGroup -> withFastStore $ \db -> getGroupChatItemIdByText db user cId (Just localDisplayName) msg CTLocal -> withFastStore $ \db -> getLocalChatItemIdByText db user cId SMDSnd msg - _ -> throwChatError $ CECommandError "not supported" + _ -> throwCmdError "not supported" getChatItemIdByText :: User -> ChatRef -> Text -> CM Int64 - getChatItemIdByText user (ChatRef cType cId) msg = case cType of + getChatItemIdByText user (ChatRef cType cId _scope) msg = case cType of CTDirect -> withFastStore $ \db -> getDirectChatItemIdByText' db user cId msg 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 + _ -> throwCmdError "not supported" + 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 @@ -2559,11 +2829,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 @@ -2572,10 +2843,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 @@ -2586,7 +2857,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) @@ -2602,7 +2873,7 @@ processChatCommand' vr = \case contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> - cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft + cId == Just contactId && s /= GSMemRejected && s /= GSMemRemoved && s /= GSMemLeft checkSndFile :: CryptoFile -> CM Integer checkSndFile (CryptoFile f cfArgs) = do fsFilePath <- lift $ toFSFilePath f @@ -2629,7 +2900,7 @@ processChatCommand' vr = \case let idsEvts = L.map ctSndEvent changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts lift $ createContactsSndFeatureItems user' changedCts' pure @@ -2656,7 +2927,7 @@ processChatCommand' vr = \case ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> - (conn, MsgFlags {notification = hasNotification XInfo_}, msgBody, [msgId]) + (conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId])) updateContactPrefs :: User -> Contact -> Preferences -> CM ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -2669,7 +2940,7 @@ processChatCommand' vr = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateProfile" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) + void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> CM ChatResponse @@ -2685,14 +2956,18 @@ processChatCommand' vr = \case GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} <- withStore $ \db -> getGroupMemberByMemberId db vr user g businessId let p'' = p' {displayName, fullName, image} :: GroupProfile - void $ sendGroupMessage user g' oldMs (XGrpInfo p'') + recipients = filter memberCurrentOrPending oldMs + void $ sendGroupMessage user g' Nothing recipients (XGrpInfo p'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' - sendGroupMessage user g' newMs $ XGrpPrefs ps' - Nothing -> sendGroupMessage user g' ms (XGrpInfo p') - let cd = CDGroupSnd g' + recipients = filter memberCurrentOrPending newMs + sendGroupMessage user g' Nothing recipients $ XGrpPrefs ps' + Nothing -> do + let recipients = filter memberCurrentOrPending ms + sendGroupMessage user g' Nothing recipients (XGrpInfo p') + let cd = CDGroupSnd g' Nothing unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g') ci] + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g' Nothing) ci] createGroupFeatureChangedItems user cd CISndGroupFeature g g' pure $ CRGroupUpdated user g g' Nothing checkValidName :: GroupName -> CM () @@ -2707,15 +2982,43 @@ processChatCommand' vr = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> CM ChatResponse - delGroupChatItems user gInfo items byGroupMember = do + delGroupChatItemsForMembers :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion] + delGroupChatItemsForMembers user gInfo chatScopeInfo ms items = do + assertDeletable gInfo items + assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate + let msgMemIds = itemsMsgMemIds gInfo items + events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds + mapM_ (sendGroupMessages_ user gInfo ms) events + delGroupChatItems user gInfo chatScopeInfo items True + where + assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () + assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items' = + unless (all itemDeletable items') $ throwChatError CEInvalidChatItemDelete + where + itemDeletable :: CChatItem 'CTGroup -> Bool + itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = + case chatDir of + CIGroupRcv GroupMember {memberRole} -> membershipMemRole >= memberRole && isJust itemSharedMsgId + CIGroupSnd -> isJust itemSharedMsgId + itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, MemberId)] + itemsMsgMemIds GroupInfo {membership = GroupMember {memberId = membershipMemId}} = mapMaybe itemMsgMemIds + where + itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, MemberId) + itemMsgMemIds (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = + join <$> forM itemSharedMsgId $ \msgId -> Just $ case chatDir of + CIGroupRcv GroupMember {memberId} -> (msgId, memberId) + CIGroupSnd -> (msgId, membershipMemId) + + delGroupChatItems :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Bool -> CM [ChatItemDeletion] + delGroupChatItems user gInfo@GroupInfo {membership} chatScopeInfo items moderation = do deletedTs <- liftIO getCurrentTime - forM_ byGroupMember $ \byMember -> do - ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci byMember deletedTs) - unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) - if groupFeatureAllowed SGFFullDelete gInfo - then deleteGroupCIs user gInfo items True False byGroupMember deletedTs - else markGroupCIsDeleted user gInfo items True byGroupMember deletedTs + when moderation $ do + ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci membership deletedTs) + unless (null ciIds) $ toView $ CEvtGroupChatItemsDeleted user gInfo ciIds True (Just membership) + let m = if moderation then Just membership else Nothing + if groupFeatureMemberAllowed SGFFullDelete membership gInfo + then deleteGroupCIs user gInfo chatScopeInfo items m deletedTs + else markGroupCIsDeleted user gInfo chatScopeInfo items m deletedTs updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> @@ -2792,9 +3095,9 @@ processChatCommand' vr = \case let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole timed_ <- contactCITimed ct ci <- saveSndChatItem' user (CDDirectSnd ct) msg content Nothing Nothing Nothing timed_ False - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) drgRandomBytes :: Int -> CM ByteString drgRandomBytes n = asks random >>= atomically . C.randomBytes n privateGetUser :: UserId -> CM User @@ -2838,11 +3141,10 @@ processChatCommand' vr = \case deleteChatUser :: User -> Bool -> CM ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withFastStore' (`getUserFileInfo` user) - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) `catchChatError` \case - e@(ChatErrorAgent NO_USER _) -> toView $ CRChatError (Just user) e + e@(ChatErrorAgent NO_USER _) -> eToView e e -> throwError e withFastStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing @@ -2859,34 +3161,79 @@ processChatCommand' vr = \case gId <- getGroupIdByName db user name GroupInfo {chatSettings} <- getGroupInfo db vr user gId pure (gId, chatSettings) - _ -> throwChatError $ CECommandError "not supported" - processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ 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" + _ -> throwCmdError "not supported" + processChatCommand $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings + 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 -> throwCmdError "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 -> eToView 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" + _ -> throwCmdError "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 -> @@ -2904,7 +3251,7 @@ processChatCommand' vr = \case | contactDeleted ct -> pure $ CPContactAddress CAPOk | otherwise -> pure $ CPContactAddress (CAPKnown ct) Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo - Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection or RcvGroupMsgConnection" + Just _ -> throwCmdError "found connection entity is not RcvDirectMsgConnection or RcvGroupMsgConnection" -- group link Just _ -> withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db vr user cReqSchemas) >>= \case @@ -2918,10 +3265,11 @@ processChatCommand' vr = \case (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | otherwise -> pure $ CPGroupLink GLPOk - (Nothing, Just _) -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + (Nothing, Just _) -> throwCmdError "found connection entity is not RcvDirectMsgConnection" (Just gInfo, _) -> groupPlan gInfo where groupPlan gInfo@GroupInfo {membership} + | memberStatus membership == GSMemRejected = pure $ CPGroupLink (GLPKnown gInfo) | not (memberActive membership) && not (memberRemoved membership) = pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo) | memberActive membership = pure $ CPGroupLink (GLPKnown gInfo) @@ -2931,134 +3279,173 @@ 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 l = do + l' <- restoreShortLink' l + (cReq, cData) <- withAgent (\a -> getConnShortLink a (aUserId user) 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 + shortenShortLink' :: ConnShortLink m -> CM (ConnShortLink m) + shortenShortLink' l = (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config) + shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) + shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' sLnk + createdGroupLink :: CreatedLinkContact -> CreatedLinkContact + createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> shortLink) + toShortGroupLink :: ShortLinkContact -> ShortLinkContact + toShortGroupLink (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 case (cInfo, content) of (DirectChat ct@Contact {contactId}, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) | status == CIGISPending -> do - let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = newStatus} memRole + let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole timed_ <- contactCITimed ct updateDirectChatItemView user ct itemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, itemId) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, itemId) _ -> pure () -- prohibited assertAllowedContent :: MsgContent -> CM () assertAllowedContent = \case - MCReport {} -> throwChatError $ CECommandError "sending reports via this API is not supported" + MCReport {} -> throwCmdError "sending reports via this API is not supported" _ -> pure () assertAllowedContent' :: ComposedMessage -> CM () - assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent - sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent + assertNoMentions :: ComposedMessage -> CM () + assertNoMentions ComposedMessage {mentions} + | null mentions = pure () + | otherwise = throwCmdError "mentions are not supported in this chat" + sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs - ct@Contact {contactUsed} <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db vr user contactId assertDirectAllowed user MDSnd ct XMsgNew_ assertVoiceAllowed ct - unless contactUsed $ withFastStore' $ \db -> updateContactUsed db user ct processComposedMessages ct where assertVoiceAllowed :: Contact -> CM () assertVoiceAllowed ct = - when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _) -> isVoice msgContent) cmrs) $ - throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) + when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _, _, _) -> isVoice msgContent) cmrs) $ + throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFVoice) processComposedMessages :: Contact -> CM ChatResponse processComposedMessages ct = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers timed_ <- sndContactCITimed live ct itemTTL (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData msgs_ cmrs ciFiles_ quotedItems_ + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch" r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live - processSendErrs user r + processSendErrs r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) deleteAt + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) deleteAt pure $ CRNewChatItems user (map (AChatItem SCTDirect SMDSnd (DirectChat ct)) cis) where setupSndFileTransfers :: CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) - prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect))) - prepareMsgs cmsFileInvs timed_ = - forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded), fInv_) -> + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect))) + prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do case (quotedItemId, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withFastStore $ \db -> getDirectChatItem db user contactId qiId + getDirectChatItem db user contactId qiId (origQmc, qd, sent) <- quoteData qci let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) - (Just _, Just _) -> throwChatError CEInvalidQuote + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Just quotedItem) + (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> CM (MsgContent, CIQDirection 'CTDirect, Bool) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote + quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwError SEInvalidQuote quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) - quoteData _ = throwChatError CEInvalidQuote - sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse - sendGroupContentMessages user groupId live itemTTL cmrs = do + quoteData _ = throwError SEInvalidQuote + sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo scope live itemTTL cmrs = do assertMultiSendable live cmrs - Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId - sendGroupContentMessages_ user gInfo ms live itemTTL cmrs - sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do - assertUserGroupRole gInfo GRAuthor + (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope modsCompatVersion + sendGroupContentMessages_ user gInfo scope chatScopeInfo recipients live itemTTL cmrs + where + hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs + modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion + sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope chatScopeInfo recipients live itemTTL cmrs = do + forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages where + allowedRole :: Maybe GroupMemberRole + allowedRole = case scope of + Nothing -> Just GRAuthor + Just (GCSMemberSupport Nothing) + | memberPending membership -> Nothing + | otherwise -> Just GRObserver + Just (GCSMemberSupport (Just _gmId)) -> Just GRModerator assertGroupContentAllowed :: CM () assertGroupContentAllowed = case findProhibited (L.toList cmrs) of - Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f)) + Just f -> throwCmdError $ "feature not allowed " <> T.unpack (groupFeatureNameText f) Nothing -> pure () where - findProhibited :: [ComposeMessageReq] -> Maybe GroupFeature + findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) Nothing - processComposedMessages :: CM ChatResponse + processComposedMessages :: CM ChatResponse processComposedMessages = do - (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) + (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL - (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo ms $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData (L.toList msgs_) cmrs ciFiles_ quotedItems_ - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live - when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" + (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ + (msgs_, gsr) <- sendGroupMessages user gInfo Nothing recipients chatMsgEvents + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData timed_ live + when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ - processSendErrs user r + processSendErrs r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> - startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) deleteAt - pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) cis) + startProximateTimedItemThread user (ChatRef CTGroup groupId scope, chatItemId' ci) deleteAt + pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) cis) where setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers n = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) - prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup))) - prepareMsgs cmsFileInvs timed_ = - forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded), fInv_) -> - prepareGroupMsg user gInfo mc quotedItemId itemForwarded fInv_ timed_ live + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup))) + prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, ciMentions), fInv_) -> + let msgScope = toMsgScope gInfo <$> chatScopeInfo + mentions = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions + in prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId itemForwarded fInv_ timed_ live createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -3094,7 +3481,7 @@ processChatCommand' vr = \case Right _ -> GSSInactive Left e -> GSSError $ SndErrOther $ tshow e forM_ ciId_ $ \ciId -> createGroupSndStatus db ciId mId status - assertMultiSendable :: Bool -> NonEmpty ComposeMessageReq -> CM () + assertMultiSendable :: Bool -> NonEmpty ComposedMessageReq -> CM () assertMultiSendable live cmrs | length cmrs == 1 = pure () | otherwise = @@ -3102,15 +3489,15 @@ processChatCommand' vr = \case -- This is to support case of sending multiple attachments while also quoting another message. -- UI doesn't allow composing with multiple quotes, so api prohibits it as well, and doesn't bother -- batching retrieval of quoted messages (prepareMsgs). - when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _) -> isJust quotedItemId) cmrs) > 1) $ - throwChatError (CECommandError "invalid multi send: live and more than one quote not supported") + when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _, _, _) -> isJust quotedItemId) cmrs) > 1) $ + throwCmdError "invalid multi send: live and more than one quote not supported" xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd) xftpSndFileTransfer user file fileSize n contactOrGroup = do (fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` eToView where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -3120,36 +3507,32 @@ processChatCommand' vr = \case saveMemberFD _ = pure () pure (fInv, ciFile) prepareSndItemsData :: + [ComposedMessageReq] -> + [Maybe (CIFile 'MDSnd)] -> + [Maybe (CIQuote c)] -> [Either ChatError SndMessage] -> - NonEmpty ComposeMessageReq -> - NonEmpty (Maybe (CIFile 'MDSnd)) -> - NonEmpty (Maybe (CIQuote c)) -> [Either ChatError (NewSndChatItemData c)] - prepareSndItemsData msgs_ cmrs' ciFiles_ quotedItems_ = - [ ( case msg_ of - Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) f q itemForwarded - Left e -> Left e -- step over original error - ) - | (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <- - zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_) - ] - processSendErrs :: User -> ([ChatError], [ChatItem c d]) -> CM () - processSendErrs user = \case + prepareSndItemsData = + zipWith4 $ \(ComposedMessage {msgContent}, itemForwarded, ts, mm) f q -> \case + Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) ts mm f q itemForwarded + Left e -> Left e -- step over original error + processSendErrs :: ([ChatError], [ChatItem c d]) -> CM () + processSendErrs = \case -- no errors ([], _) -> pure () -- at least one item is successfully created - (errs, _ci : _) -> toView $ CRChatErrors (Just user) errs + (errs, _ci : _) -> toView $ CEvtChatErrors errs -- single error ([err], []) -> throwError err -- multiple errors (errs@(err : _), []) -> do - toView $ CRChatErrors (Just user) errs + toView $ CEvtChatErrors errs throwError err getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect]) getCommandDirectChatItems user ctId itemIds = do ct <- withFastStore $ \db -> getContact db vr user ctId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure (ct, items) where getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) @@ -3157,17 +3540,17 @@ processChatCommand' vr = \case getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup]) getCommandGroupChatItems user gId itemIds = do gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) + unless (null errs) $ toView $ CEvtChatErrors errs pure (gInfo, items) where - getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) - getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId + getGroupCI :: DB.Connection -> GroupInfo -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) + getGroupCI db gInfo itemId = runExceptT . withExceptT ChatErrorStore $ getGroupCIWithReactions db user gInfo itemId getCommandLocalChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (NoteFolder, [CChatItem 'CTLocal]) getCommandLocalChatItems user nfId itemIds = do nf <- withStore $ \db -> getNoteFolder db user nfId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure (nf, items) where getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal)) @@ -3177,7 +3560,7 @@ processChatCommand' vr = \case forwardMsgContent ChatItem {content = CISndMsgContent fmc} = pure $ Just fmc forwardMsgContent ChatItem {content = CIRcvMsgContent fmc} = pure $ Just fmc forwardMsgContent _ = throwChatError CEInvalidForward - createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposeMessageReq -> CM ChatResponse + createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposedMessageReq -> CM ChatResponse createNoteFolderContentItems user folderId cmrs = do assertNoQuotes nf <- withFastStore $ \db -> getNoteFolder db user folderId @@ -3189,11 +3572,11 @@ processChatCommand' vr = \case where assertNoQuotes :: CM () assertNoQuotes = - when (any (\(ComposedMessage {quotedItemId}, _) -> isJust quotedItemId) cmrs) $ - throwChatError (CECommandError "createNoteFolderContentItems: quotes not supported") + when (any (\(ComposedMessage {quotedItemId}, _, _, _) -> isJust quotedItemId) cmrs) $ + throwCmdError "createNoteFolderContentItems: quotes not supported" createLocalFiles :: NoteFolder -> UTCTime -> CM (NonEmpty (Maybe (CIFile 'MDSnd))) createLocalFiles nf createdAt = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do fsFilePath <- lift $ toFSFilePath filePath fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs @@ -3202,16 +3585,20 @@ processChatCommand' vr = \case fileId <- createLocalFile CIFSSndStored db user nf createdAt cf fileSize chunkSize pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal} prepareLocalItemsData :: - NonEmpty ComposeMessageReq -> + NonEmpty ComposedMessageReq -> NonEmpty (Maybe (CIFile 'MDSnd)) -> - [(CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom)] - prepareLocalItemsData cmrs' ciFiles_ = - [ (CISndMsgContent mc, f, itemForwarded) - | ((ComposedMessage {msgContent = mc}, itemForwarded), f) <- zip (L.toList cmrs') (L.toList ciFiles_) - ] + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + prepareLocalItemsData = + L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts, _) f -> + (CISndMsgContent mc, f, itemForwarded, ts) getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId) + withSendRef :: ChatRef -> (SendRef -> CM ChatResponse) -> CM ChatResponse + withSendRef chatRef a = case chatRef of + ChatRef CTDirect cId _ -> a $ SRDirect cId + ChatRef CTGroup gId scope -> a $ SRGroup gId scope + _ -> throwCmdError "not supported" protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) protocolServers p (operators, smpServers, xftpServers) = case p of @@ -3230,7 +3617,18 @@ updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = disableSrv srv@UserServer {preset} = AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} -type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) + +composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage +composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} + +composedMessageReq :: ComposedMessage -> ComposedMessageReq +composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgContentTexts mc, M.empty) + +composedMessageReqMentions :: DB.Connection -> User -> GroupInfo -> ComposedMessage -> ExceptT StoreError IO ComposedMessageReq +composedMessageReqMentions db user g cm@ComposedMessage {msgContent = mc, mentions} = do + let ts@(_, ft_) = msgContentTexts mc + (cm,Nothing,ts,) <$> getCIMentions db user g ft_ mentions data ChangedProfileContact = ChangedProfileContact { ct :: Contact, @@ -3277,7 +3675,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' delay interval <- asks $ ciExpirationInterval . config forever $ do - flip catchChatError' (toView' . CRChatError (Just user)) $ do + flip catchChatError' (eToView') $ do expireFlags <- asks expireCIFlags atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated @@ -3286,7 +3684,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' interval setChatItemsExpiration :: User -> Int64 -> Int -> CM' () -setChatItemsExpiration user newTTL ttlCount +setChatItemsExpiration user newTTL ttlCount | newTTL > 0 || ttlCount > 0 = do startExpireCIThread user whenM chatStarted $ setExpireCIFlag user True @@ -3309,7 +3707,7 @@ agentSubscriber = do q <- asks $ subQ . smpAgent forever (atomically (readTBQueue q) >>= process) `E.catchAny` \e -> do - toView' $ CRChatError Nothing $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing + eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing E.throwIO e where process :: (ACorrId, AEntityId, AEvt) -> CM' () @@ -3319,7 +3717,7 @@ agentSubscriber = do SAERcvFile -> processAgentMsgRcvFile corrId entId msg SAESndFile -> processAgentMsgSndFile corrId entId msg where - run action = action `catchChatError'` (toView' . CRChatError Nothing) + run action = action `catchChatError'` (eToView') type AgentBatchSubscribe = AgentClient -> [ConnId] -> ExceptT AgentErrorType IO (Map ConnId (Either AgentErrorType ())) @@ -3347,17 +3745,17 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce - -- TODO possibly, we could either disable these events or replace with less noisy for API - contactLinkSubsToView rs ucs - groupSubsToView rs gs ms ce - sndFileSubsToView rs sfts - rcvFileSubsToView rs rfts - pendingConnSubsToView rs pcs + unlessM (asks $ coreApi . config) $ do + contactLinkSubsToView rs ucs + groupSubsToView rs gs ms ce + sndFileSubsToView rs sfts + rcvFileSubsToView rs rfts + pendingConnSubsToView rs pcs where addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs') - RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs) + RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs) SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) @@ -3372,11 +3770,18 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do viaUserContactLink, groupLinkId, customUserProfileId, - connReqInv = Nothing, + connLinkInv = Nothing, localAlias, createdAt, updatedAt = createdAt } + toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} = + ShortGroupMember + { groupMemberId, + groupId, + memberName = localDisplayName, + connId = agentConnId + } getContactConns :: CM ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ (`getUserContacts` vr) @@ -3387,11 +3792,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) - getGroupMemberConns :: CM ([Group], [ConnId], Map ConnId GroupMember) + getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember) getGroupMemberConns = do - gs <- withStore_ (`getUserGroups` vr) - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs + gs <- withStore_ getUserGroupsToSubscribe + let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs pure (gs, map fst mPairs, M.fromList mPairs) + where + shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do sfts <- withStore_ getLiveSndFileTransfers @@ -3415,9 +3822,9 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do notifyCLI = do let cRs = resultsFor rs cts cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs - toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors - notifyAPI = toView . CRNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) + toView . CEvtContactSubSummary user $ map (uncurry ContactSubStatus) cRs + when ce $ mapM_ (toView . uncurry (CEvtContactSubError user)) cErrors + notifyAPI = toView . CEvtNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) statuses = M.foldrWithKey' addStatus [] cts where addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] @@ -3434,47 +3841,44 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do e -> show e -- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM () - contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs - groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> CM () + contactLinkSubsToView rs = toView . CEvtUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs + groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () groupSubsToView rs gs ms ce = do mapM_ groupSub $ - sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs - toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs + sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs + toViewTE . TEMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms - groupSub :: Group -> CM () - groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do - when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors - toView groupEvent + groupSub :: ShortGroup -> CM () + groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do + when ce $ mapM_ (toViewTE . uncurry (TEMemberSubError user g)) mErrors + toViewTE groupEvent where - mErrors :: [(GroupMember, ChatError)] + mErrors :: [(ShortGroupMember, ChatError)] mErrors = - sortOn (\(GroupMember {localDisplayName = n}, _) -> n) + sortOn (\(ShortGroupMember {memberName = n}, _) -> n) . filterErrors - $ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs - groupEvent :: ChatResponse + $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs + groupEvent :: TerminalEvent groupEvent - | memberStatus membership == GSMemInvited = CRGroupInvitation user g - | all (\GroupMember {activeConn} -> isNothing activeConn) members = - if memberActive membership - then CRGroupEmpty user g - else CRGroupRemoved user g - | otherwise = CRGroupSubscribed user g + | membershipStatus == GSMemInvited = TEGroupInvitation user g + | null members = TEGroupEmpty user g + | otherwise = TEGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM () sndFileSubsToView rs sfts = do let sftRs = resultsFor rs sfts forM_ sftRs $ \(ft@SndFileTransfer {fileId, fileStatus}, err_) -> do - forM_ err_ $ toView . CRSndFileSubError user ft + forM_ err_ $ toViewTE . TESndFileSubError user ft void . forkIO $ do threadDelay 1000000 when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withChatLock "subscribe sendFileChunk" $ sendFileChunk user ft rcvFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId RcvFileTransfer -> CM () - rcvFileSubsToView rs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . filterErrors . resultsFor rs + rcvFileSubsToView rs = mapM_ (toViewTE . uncurry (TERcvFileSubError user)) . filterErrors . resultsFor rs pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> CM () - pendingConnSubsToView rs = toView . CRPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs + pendingConnSubsToView rs = toViewTE . TEPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> CM [a] - withStore_ a = withStore' (`a` user) `catchChatError` \e -> toView (CRChatError (Just user) e) $> [] + withStore_ a = withStore' (`a` user) `catchChatError` \e -> eToView e $> [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -3496,27 +3900,28 @@ cleanupManager = do liftIO $ threadDelay' initialDelay stepDelay <- asks (cleanupManagerStepDelay . config) forever $ do - flip catchChatError (toView . CRChatError Nothing) $ do + flip catchChatError eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay - cleanupMessages `catchChatError` (toView . CRChatError Nothing) + cleanupMessages `catchChatError` eToView -- TODO possibly, also cleanup async commands - cleanupProbes `catchChatError` (toView . CRChatError Nothing) + cleanupProbes `catchChatError` eToView liftIO $ threadDelay' $ diffToMicroseconds interval where - runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CRChatError Nothing) $ do + runWithoutInitialDelay cleanupInterval = flip catchChatError eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users - forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) - forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) + forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView + forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView cleanupUser cleanupInterval stepDelay user = do - cleanupTimedItems cleanupInterval user `catchChatError` (toView . CRChatError (Just user)) + cleanupTimedItems cleanupInterval user `catchChatError` eToView liftIO $ threadDelay' stepDelay - cleanupDeletedContacts user `catchChatError` (toView . CRChatError (Just user)) + -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted + cleanupDeletedContacts user `catchChatError` eToView liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime @@ -3528,7 +3933,7 @@ cleanupManager = do contacts <- withStore' $ \db -> getDeletedContacts db vr user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) - `catchChatError` (toView . CRChatError (Just user)) + `catchChatError` eToView cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts @@ -3554,7 +3959,7 @@ expireChatItems user@User {userId} globalTTL sync = do loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do - process a `catchChatError` (toView . CRChatError (Just user)) + process a `catchChatError` eToView loop as process continue :: CM () -> CM () continue a = @@ -3575,8 +3980,7 @@ expireContactChatItems user vr globalTTL ctId = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do lift waitChatStartedAndActivated filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () @@ -3587,8 +3991,7 @@ expireGroupChatItems user vr globalTTL createdAtCutoff groupId = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do lift waitChatStartedAndActivated filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m @@ -3676,7 +4079,7 @@ chatCommandP = "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), - "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create tag " *> (APICreateChatTag <$> jsonP), "/_tags " *> (APISetChatTags <$> chatRefP <*> optional _strP), "/_delete tag " *> (APIDeleteChatTag <$> A.decimal), @@ -3685,11 +4088,13 @@ chatCommandP = "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), "/report #" *> (ReportMessage <$> displayNameP <*> optional (" @" *> displayNameP) <*> _strP <* A.space <*> msgTextP), - "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), + "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <*> (" json" *> jsonP <|> " text " *> updatedMessagesTextP)), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), - "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), - "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), + "/_archive reports #" *> (APIArchiveReceivedReports <$> A.decimal), + "/_delete reports #" *> (APIDeleteReceivedReports <$> A.decimal <*> _strP <*> _strP), + "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> (knownReaction <$?> jsonP)), + "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> (knownReaction <$?> jsonP)), "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), "/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP), "/_read user " *> (APIUserRead <$> A.decimal), @@ -3718,20 +4123,24 @@ chatCommandP = "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), "/_set theme " *> (APISetChatUIThemes <$> chatRefP <*> optional (A.space *> jsonP)), - "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString), "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), + "/_ntf check " *> (APICheckToken <$> strP), "/_ntf delete " *> (APIDeleteToken <$> strP), "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), - "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), + "/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI - "/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole), - "/_block #" *> (APIBlockMemberForAll <$> A.decimal <* A.space <*> A.decimal <* A.space <* "blocked=" <*> onOffP), - "/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal), + "/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), + "/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal), + "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), + "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* " blocked=" <*> onOffP), + "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)), "/_leave #" *> (APILeaveGroup <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal), + -- "/_archive conversations #" *> (APIArchiveGroupConversations <$> A.decimal <*> _strP), + -- "/_delete conversations #" *> (APIDeleteGroupConversations <$> A.decimal <*> _strP), "/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP), "/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP), "/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP), @@ -3812,10 +4221,11 @@ chatCommandP = "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)), + "/accept member " *> char_ '#' *> (AcceptMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> memberRole), "/block for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/unblock for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), - ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (S.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',') <*> (" messages=" *> onOffP <|> pure False)), ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP), ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP), ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode), @@ -3823,6 +4233,7 @@ chatCommandP = "/clear #" *> (ClearGroup <$> displayNameP), "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), + "/member support chats #" *> (ListMemberSupportChats <$> displayNameP), "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), @@ -3832,11 +4243,12 @@ 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)), + "/_short link #" *> (APIAddGroupShortLink <$> A.decimal), + "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember) <*> shortP), "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), "/delete link #" *> (DeleteGroupLink <$> displayNameP), "/show link #" *> (ShowGroupLink <$> displayNameP), @@ -3847,18 +4259,17 @@ 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, ForwardLocalMessage <$> chatNameP <* " <- * " <*> msgTextP, - SendMessage <$> chatNameP <* A.space <*> msgTextP, - "/* " *> (SendMessage (ChatName CTLocal "") <$> msgTextP), + SendMessage <$> sendNameP <* A.space <*> msgTextP, "@#" *> (SendMemberContactMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> msgTextP), "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), @@ -3867,7 +4278,7 @@ chatCommandP = ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> textP), ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> msgTextP), ReactToMessage <$> (("+" $> True) <|> ("-" $> False)) <*> reactionP <* A.space <*> chatNameP' <* A.space <*> textP, - "/feed " *> (SendMessageBroadcast <$> msgTextP), + "/feed " *> (SendMessageBroadcast . MCText <$> msgTextP), ("/chats" <|> "/cs") *> (LastChats <$> (" all" $> Nothing <|> Just <$> (A.space *> A.decimal <|> pure 20))), ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing), ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> stringP))), @@ -3886,12 +4297,13 @@ 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), ("/show_address" <|> "/sa") $> ShowMyAddress, + "/_short_link_address " *> (APIAddMyAddressShortLink <$> A.decimal), "/_profile_address " *> (APISetProfileAddress <$> A.decimal <* A.space <*> onOffP), ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), @@ -3913,14 +4325,16 @@ chatCommandP = "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayNameP <*> (A.space *> strP)), "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayNameP <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), - "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayNameP <*> (A.space *> strP)), + "/set delete #" *> (SetGroupFeatureRole (AGFR SGFFullDelete) <$> displayNameP <*> _strP <*> optional memberRole), "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayNameP <*> optional (A.space *> strP)), "/set delete " *> (SetUserFeature (ACF SCFFullDelete) <$> strP), "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayNameP <*> _strP <*> optional memberRole), "/set disappear #" *> (SetGroupTimedMessages <$> displayNameP <*> (A.space *> timedTTLOnOffP)), "/set disappear @" *> (SetContactTimedMessages <$> displayNameP <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), + "/set reports #" *> (SetGroupFeature (AGFNR SGFReports) <$> displayNameP <*> _strP), "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), + "/set admission review #" *> (SetGroupMemberAdmissionReview <$> displayNameP <*> (A.space *> memberCriteriaP)), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, @@ -3956,7 +4370,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)) @@ -3996,7 +4415,8 @@ chatCommandP = c -> c composedMessagesTextP = do text <- mcTextP - pure $ (ComposedMessage Nothing Nothing text) :| [] + pure [composedMessage Nothing text] + updatedMessagesTextP = (`UpdatedMessage` []) <$> mcTextP liveMessageP = " live=" *> onOffP <|> pure False sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing receiptSettings = do @@ -4019,7 +4439,8 @@ chatCommandP = { directMessages = Just DirectMessagesGroupPreference {enable = FEOn, role = Nothing}, history = Just HistoryGroupPreference {enable = FEOn} } - pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences} + pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences, memberAdmission = Nothing} + memberCriteriaP = ("all" $> Just MCAll) <|> ("off" $> Nothing) fullNameP = A.space *> textP <|> pure "" textP = safeDecodeUtf8 <$> A.takeByteString pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) @@ -4031,6 +4452,12 @@ chatCommandP = cfArgs <- optional $ CFArgs <$> (" key=" *> strP <* A.space) <*> (" nonce=" *> strP) path <- filePath pure $ CryptoFile path cfArgs + connMsgsP = L.fromList <$> connMsgP `A.sepBy1'` A.char ',' + connMsgP = do + AgentConnId msgConnId <- strP <* A.char ':' + msgDbQueueId <- strP <* A.char ':' + ts <- strP + pure ConnMsgReq {msgConnId, msgDbQueueId, msgTs = Just ts} memberRole = A.choice [ " owner" $> GROwner, @@ -4044,7 +4471,29 @@ chatCommandP = CTLocal -> pure $ ChatName CTLocal "" ct -> ChatName ct <$> displayNameP chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP - chatRefP = ChatRef <$> chatTypeP <*> A.decimal + chatRefP = do + chatTypeP >>= \case + CTGroup -> ChatRef CTGroup <$> A.decimal <*> optional gcScopeP + cType -> (\chatId -> ChatRef cType chatId Nothing) <$> A.decimal + sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP) + gcScopeP = "(_support" *> (GCSMemberSupport <$> optional (A.char ':' *> A.decimal)) <* A.char ')' + sendNameP = + (A.char '@' $> SNDirect <*> displayNameP) + <|> (A.char '#' $> SNGroup <*> displayNameP <*> gScopeNameP) + <|> ("/*" $> SNLocal) + gScopeNameP = + (supportPfx *> (Just . GSNMemberSupport <$> optional supportMember) <* A.char ')') + -- this branch fails on "(support" followed by incorrect syntax, + -- to avoid sending message to the whole group as `optional gScopeNameP` would do + <|> (optional supportPfx >>= mapM (\_ -> fail "bad chat scope")) + where + supportPfx = A.takeWhile isSpace *> "(support" + supportMember = safeDecodeUtf8 <$> (A.char ':' *> A.takeWhile isSpace *> (A.take . lengthTillLastParen =<< A.lookAhead displayNameP_)) + lengthTillLastParen s = case B.unsnoc s of + Just (_, ')') -> B.length s - 1 + _ -> B.length s msgCountP = A.space *> A.decimal <|> pure 10 ciTTLDecimal = ("default" $> Nothing) <|> (Just <$> A.decimal) ciTTL = @@ -4090,11 +4539,11 @@ chatCommandP = requiredHostMode <- (" required-host-mode" $> True) <|> pure False smpProxyMode_ <- optional $ " smp-proxy=" *> strP smpProxyFallback_ <- optional $ " smp-proxy-fallback=" *> strP - smpWebPort <- (" smp-web-port" $> True) <|> pure False + smpWebPortServers <- (" smp-web-port-servers=" *> strP) <|> (" smp-web-port" $> SWPAll) <|> pure SWPPreset t_ <- optional $ " timeout=" *> A.decimal logTLSErrors <- " log=" *> onOffP <|> pure False let tcpTimeout_ = (1000000 *) <$> t_ - pure $ SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} + pure $ SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPortServers, tcpTimeout_, logTLSErrors} #if !defined(dbPostgres) dbKeyP = nonEmptyKey <$?> strP nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k @@ -4110,16 +4559,20 @@ chatCommandP = char_ = optional . A.char displayNameP :: Parser Text -displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',')) +displayNameP = safeDecodeUtf8 <$> displayNameP_ +{-# INLINE displayNameP #-} + +displayNameP_ :: Parser ByteString +displayNameP_ = quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',') where takeNameTill p = A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" quoted c = A.char c *> takeNameTill (== c) <* A.char c - refChar c = c > ' ' && c /= '#' && c /= '@' + refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' mkValidName :: String -> String -mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) +mkValidName = dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where fst3 (x, _, _) = x addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1bcbf9f20d..bdc5e4b920 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -29,14 +29,16 @@ import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Char (isDigit) +import Data.Containers.ListUtils (nubOrd) import Data.Either (partitionEithers, rights) import Data.Fixed (div') import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) -import Data.List (find, mapAccumL, partition) -import Data.List.NonEmpty (NonEmpty (..)) +import Data.List (find, foldl', mapAccumL, partition) +import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -59,6 +61,7 @@ import Simplex.Chat.Operators import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store +import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups @@ -75,7 +78,7 @@ import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (getFastNetworkConfig, ipAddressProtected, withLockMap) -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), ServerCfg (..)) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) @@ -102,6 +105,12 @@ import UnliftIO.STM maxMsgReactions :: Int maxMsgReactions = 3 +maxRcvMentions :: Int +maxRcvMentions = 5 + +maxSndMentions :: Int +maxSndMentions = 3 + withChatLock :: String -> CM a -> CM a withChatLock name action = asks chatLock >>= \l -> withLock l name action @@ -175,31 +184,117 @@ callTimed ct aciContent = aciContentCallStatus (ACIContent _ (CIRcvCall st _)) = Just st aciContentCallStatus _ = Nothing -toggleNtf :: User -> GroupMember -> Bool -> CM () -toggleNtf user m ntfOn = +toggleNtf :: GroupMember -> Bool -> CM () +toggleNtf m ntfOn = when (memberActive m) $ forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` eToView -prepareGroupMsg :: User -> GroupInfo -> MsgContent -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> CM (MsgContainer, Maybe (CIQuote 'CTGroup)) -prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) +prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of + (Nothing, Nothing) -> + let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope + in pure (XMsgNew mc', Nothing) + (Nothing, Just _) -> + let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope + in pure (XMsgNew mc', Nothing) (Just quotedItemId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withStore $ \db -> getGroupChatItem db user groupId quotedItemId + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- + getGroupCIWithReactions db user g quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} qmc = quoteContent mc origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) - (Just _, Just _) -> throwChatError CEInvalidQuote + (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} + mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope) + pure (XMsgNew mc', Just quotedItem) + (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> GroupMember -> CM (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote + quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwError SEInvalidQuote quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) - quoteData _ _ = throwChatError CEInvalidQuote + quoteData _ _ = throwError SEInvalidQuote + +updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) +updatedMentionNames mc ft_ mentions = case ft_ of + Just ft + | not (null ft) && not (null mentions) && not (all sameName $ M.assocs mentions) -> + let (mentions', ft') = mapAccumL update M.empty ft + text = T.concat $ map markdownText ft' + in (mc {text} :: MsgContent, Just ft', mentions') + _ -> (mc, ft_, mentions) + where + sameName (name, CIMention {memberRef}) = case memberRef of + Just CIMentionMember {displayName} -> case T.stripPrefix displayName name of + Just rest + | T.null rest -> True + | otherwise -> case T.uncons rest of + Just ('_', suffix) -> T.all isDigit suffix + _ -> False + Nothing -> False + Nothing -> True + update mentions' ft@(FormattedText f _) = case f of + Just (Mention name) -> case M.lookup name mentions of + Just mm@CIMention {memberRef} -> + let name' = uniqueMentionName 0 $ case memberRef of + Just CIMentionMember {displayName} -> displayName + Nothing -> name + in (M.insert name' mm mentions', FormattedText (Just $ Mention name') ('@' `T.cons` viewName name')) + Nothing -> (mentions', ft) + _ -> (mentions', ft) + where + uniqueMentionName :: Int -> Text -> Text + uniqueMentionName pfx name = + let prefixed = if pfx == 0 then name else (name `T.snoc` '_') <> tshow pfx + in if prefixed `M.member` mentions' then uniqueMentionName (pfx + 1) name else prefixed + +getCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName GroupMemberId -> ExceptT StoreError IO (Map MemberName CIMention) +getCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null ft) && not (null mentions) -> do + let msgMentions = S.fromList $ mentionedNames ft + n = M.size mentions + -- prevent "invisible" and repeated-with-different-name mentions (when the same member is mentioned via another name) + unless (n <= maxSndMentions && all (`S.member` msgMentions) (M.keys mentions) && S.size (S.fromList $ M.elems mentions) == n) $ + throwError SEInvalidMention + mapM (getMentionedGroupMember db user groupId) mentions + _ -> pure M.empty + +getRcvCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MsgMention -> IO (Map MemberName CIMention) +getRcvCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft + | not (null ft) && not (null mentions) -> + let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft + in mapM (getMentionedMemberByMemberId db user groupId) mentions' + _ -> pure M.empty + +-- prevent "invisible" and repeated-with-different-name mentions +uniqueMsgMentions :: Int -> Map MemberName MsgMention -> [ContactName] -> Map MemberName MsgMention +uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 + where + go acc _ _ [] = acc + go acc seen n (name : rest) + | n >= maxMentions = acc + | otherwise = case M.lookup name mentions of + Just mm@MsgMention {memberId} + | S.notMember memberId seen -> + go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest + _ -> go acc seen n rest + +getMessageMentions :: DB.Connection -> User -> GroupId -> Text -> IO (Map MemberName GroupMemberId) +getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of + Just ft | not (null ft) -> M.fromList . catMaybes <$> mapM get (nubOrd $ mentionedNames ft) + _ -> pure M.empty + where + get name = + fmap (name,) . eitherToMaybe + <$> runExceptT (getGroupMemberIdByName db user gId name) + +msgContentTexts :: MsgContent -> (Text, Maybe MarkdownList) +msgContentTexts mc = let t = msgContentText mc in (t, parseMaybeMarkdownList t) + +ciContentTexts :: CIContent d -> (Text, Maybe MarkdownList) +ciContentTexts content = let t = ciContentToText content in (t, parseMaybeMarkdownList t) quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent mc qmc ciFile_ @@ -228,17 +323,23 @@ quoteContent mc qmc ciFile_ qFileName = maybe qText (T.pack . getFileName) ciFile_ qTextOrFile = if T.null qText then qFileName else qText -prohibitedGroupContent :: GroupInfo -> GroupMember -> MsgContent -> Maybe f -> Maybe GroupFeature -prohibitedGroupContent gInfo m mc file_ +prohibitedGroupContent :: GroupInfo -> GroupMember -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature +prohibitedGroupContent gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice - | not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles - | prohibitedSimplexLinks gInfo m mc = Just GFSimplexLinks + | isNothing scopeInfo && not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles + | isNothing scopeInfo && isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports + | isNothing scopeInfo && prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks | otherwise = Nothing + where + -- admins cannot send reports, non-admins cannot receive reports + badReportUser + | sent = userRole >= GRModerator + | otherwise = userRole < GRModerator -prohibitedSimplexLinks :: GroupInfo -> GroupMember -> MsgContent -> Bool -prohibitedSimplexLinks gInfo m mc = +prohibitedSimplexLinks :: GroupInfo -> GroupMember -> Maybe MarkdownList -> Bool +prohibitedSimplexLinks gInfo m ft = not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo) - && maybe False (any ftIsSimplexLink) (parseMaybeMarkdownList $ msgContentText mc) + && maybe False (any ftIsSimplexLink) ft where ftIsSimplexLink :: FormattedText -> Bool ftIsSimplexLink FormattedText {format} = maybe False isSimplexLink format @@ -287,8 +388,8 @@ cancelFilesInProgress user filesInfo = do lift $ agentXFTPDeleteRcvFiles xrfIds let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs smpRFConnIds = mapMaybe smpRcvFileConnId rfs - deleteAgentConnectionsAsync user smpSFConnIds - deleteAgentConnectionsAsync user smpRFConnIds + deleteAgentConnectionsAsync smpSFConnIds + deleteAgentConnectionsAsync smpRFConnIds where fileEnded CIFileInfo {fileStatus} = case fileStatus of Just (AFS _ status) -> ciFileEnded status @@ -340,39 +441,90 @@ deleteFilesLocally files = withFilesFolder :: (FilePath -> CM ()) -> CM () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action -deleteDirectCIs :: User -> Contact -> [CChatItem 'CTDirect] -> Bool -> Bool -> CM ChatResponse -deleteDirectCIs user ct items byUser timed = do +deleteDirectCIs :: User -> Contact -> [CChatItem 'CTDirect] -> CM [ChatItemDeletion] +deleteDirectCIs user ct items = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteCIFiles user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser timed + unless (null errs) $ toView $ CEvtChatErrors errs + pure deletions where deleteItem db (CChatItem md ci) = do deleteDirectChatItem db user ct ci pure $ contactDeletion md ct ci Nothing -deleteGroupCIs :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> CM ChatResponse -deleteGroupCIs user gInfo items byUser timed byGroupMember_ deletedTs = do +deleteGroupCIs :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion] +deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteCIFiles user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser timed + unless (null errs) $ toView $ CEvtChatErrors errs + vr <- chatVersionRange + deletions' <- case chatScopeInfo of + Nothing -> pure deletions + Just scopeInfo@GCSIMemberSupport {groupMember_} -> do + let decStats = countDeletedUnreadItems groupMember_ deletions + gInfo' <- withFastStore' $ \db -> updateGroupScopeUnreadStats db vr user gInfo scopeInfo decStats + pure $ map (updateDeletionGroupInfo gInfo') deletions + pure deletions' where deleteItem :: DB.Connection -> CChatItem 'CTGroup -> IO ChatItemDeletion deleteItem db (CChatItem md ci) = do ci' <- case byGroupMember_ of Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs Nothing -> Nothing <$ deleteGroupChatItem db user gInfo ci - pure $ groupDeletion md gInfo ci ci' + pure $ groupDeletion md gInfo chatScopeInfo ci ci' + countDeletedUnreadItems :: Maybe GroupMember -> [ChatItemDeletion] -> (Int, Int, Int) + countDeletedUnreadItems scopeMember_ = foldl' countItem (0, 0, 0) + where + countItem :: (Int, Int, Int) -> ChatItemDeletion -> (Int, Int, Int) + countItem (!unread, !unanswered, !mentions) ChatItemDeletion {deletedChatItem} + | aChatItemIsRcvNew deletedChatItem = + let unread' = unread + 1 + unanswered' = case (scopeMember_, aChatItemRcvFromMember deletedChatItem) of + (Just scopeMember, Just rcvFromMember) + | groupMemberId' rcvFromMember == groupMemberId' scopeMember -> unanswered + 1 + _ -> unanswered + mentions' = if isACIUserMention deletedChatItem then mentions + 1 else mentions + in (unread', unanswered', mentions') + | otherwise = (unread, unanswered, mentions) + updateDeletionGroupInfo :: GroupInfo -> ChatItemDeletion -> ChatItemDeletion + updateDeletionGroupInfo gInfo' ChatItemDeletion {deletedChatItem, toChatItem} = + ChatItemDeletion + { deletedChatItem = updateACIGroupInfo gInfo' deletedChatItem, + toChatItem = updateACIGroupInfo gInfo' <$> toChatItem + } + +updateACIGroupInfo :: GroupInfo -> AChatItem -> AChatItem +updateACIGroupInfo gInfo' = \case + AChatItem SCTGroup dir (GroupChat _gInfo chatScopeInfo) ci -> + AChatItem SCTGroup dir (GroupChat gInfo' chatScopeInfo) ci + aci -> aci + +deleteGroupMemberCIs :: MsgDirectionI d => User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> CM () +deleteGroupMemberCIs user gInfo member byGroupMember msgDir = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> deleteGroupMemberCIs_ db user gInfo member byGroupMember msgDir deletedTs + deleteCIFiles user filesInfo + +deleteGroupMembersCIs :: User -> GroupInfo -> [GroupMember] -> GroupMember -> CM () +deleteGroupMembersCIs user gInfo members byGroupMember = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> fmap concat $ forM members $ \m -> deleteGroupMemberCIs_ db user gInfo m byGroupMember SMDRcv deletedTs + deleteCIFiles user filesInfo + +deleteGroupMemberCIs_ :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> UTCTime -> IO [CIFileInfo] +deleteGroupMemberCIs_ db user gInfo member byGroupMember msgDir deletedTs = do + fs <- getGroupMemberFileInfo db user gInfo member + updateMemberCIsModerated db user gInfo member byGroupMember msgDir deletedTs + pure fs deleteLocalCIs :: User -> NoteFolder -> [CChatItem 'CTLocal] -> Bool -> Bool -> CM ChatResponse deleteLocalCIs user nf items byUser timed = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteFilesLocally ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRChatItemsDeleted user deletions byUser timed where deleteItem db (CChatItem md ci) = do @@ -386,34 +538,53 @@ deleteCIFiles user filesInfo = do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo -markDirectCIsDeleted :: User -> Contact -> [CChatItem 'CTDirect] -> Bool -> UTCTime -> CM ChatResponse -markDirectCIsDeleted user ct items byUser deletedTs = do +markDirectCIsDeleted :: User -> Contact -> [CChatItem 'CTDirect] -> UTCTime -> CM [ChatItemDeletion] +markDirectCIsDeleted user ct items deletedTs = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items cancelFilesInProgress user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser False + unless (null errs) $ toView $ CEvtChatErrors errs + pure deletions where markDeleted db (CChatItem md ci) = do ci' <- markDirectChatItemDeleted db user ct ci deletedTs pure $ contactDeletion md ct ci (Just ci') -markGroupCIsDeleted :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> Maybe GroupMember -> UTCTime -> CM ChatResponse -markGroupCIsDeleted user gInfo items byUser byGroupMember_ deletedTs = do +markGroupCIsDeleted :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion] +markGroupCIsDeleted user gInfo chatScopeInfo items byGroupMember_ deletedTs = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items cancelFilesInProgress user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser False + unless (null errs) $ toView $ CEvtChatErrors errs + pure deletions + -- pure $ CRChatItemsDeleted user deletions byUser False where markDeleted db (CChatItem md ci) = do ci' <- markGroupChatItemDeleted db user gInfo ci byGroupMember_ deletedTs - pure $ groupDeletion md gInfo ci (Just ci') + pure $ groupDeletion md gInfo chatScopeInfo ci (Just ci') -groupDeletion :: MsgDirectionI d => SMsgDirection d -> GroupInfo -> ChatItem 'CTGroup d -> Maybe (ChatItem 'CTGroup d) -> ChatItemDeletion -groupDeletion md g ci ci' = ChatItemDeletion (gItem ci) (gItem <$> ci') +markGroupMemberCIsDeleted :: User -> GroupInfo -> GroupMember -> GroupMember -> CM () +markGroupMemberCIsDeleted user gInfo member byGroupMember = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> markGroupMemberCIsDeleted_ db user gInfo member byGroupMember deletedTs + cancelFilesInProgress user filesInfo + +markGroupMembersCIsDeleted :: User -> GroupInfo -> [GroupMember] -> GroupMember -> CM () +markGroupMembersCIsDeleted user gInfo members byGroupMember = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> fmap concat $ forM members $ \m -> markGroupMemberCIsDeleted_ db user gInfo m byGroupMember deletedTs + cancelFilesInProgress user filesInfo + +markGroupMemberCIsDeleted_ :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> UTCTime -> IO [CIFileInfo] +markGroupMemberCIsDeleted_ db user gInfo member byGroupMember deletedTs = do + fs <- getGroupMemberFileInfo db user gInfo member + markMemberCIsDeleted db user gInfo member byGroupMember deletedTs + pure fs + +groupDeletion :: MsgDirectionI d => SMsgDirection d -> GroupInfo -> Maybe GroupChatScopeInfo -> ChatItem 'CTGroup d -> Maybe (ChatItem 'CTGroup d) -> ChatItemDeletion +groupDeletion md g chatScopeInfo ci ci' = ChatItemDeletion (gItem ci) (gItem <$> ci') where - gItem = AChatItem SCTGroup md (GroupChat g) + gItem = AChatItem SCTGroup md (GroupChat g chatScopeInfo) contactDeletion :: MsgDirectionI d => SMsgDirection d -> Contact -> ChatItem 'CTDirect d -> Maybe (ChatItem 'CTDirect d) -> ChatItemDeletion contactDeletion md ct ci ci' = ChatItemDeletion (ctItem ci) (ctItem <$> ci') @@ -427,12 +598,12 @@ updateCallItemStatus user ct@Contact {contactId} Call {chatItemId} receivedStatu timed_ <- callTimed ct aciContent updateDirectChatItemView user ct chatItemId aciContent False False timed_ msgId_ forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId) updateDirectChatItemView :: User -> Contact -> ChatItemId -> ACIContent -> Bool -> Bool -> Maybe CITimed -> Maybe MessageId -> CM () updateDirectChatItemView user ct chatItemId (ACIContent msgDir ciContent) edited live timed_ msgId_ = do ci' <- withStore $ \db -> updateDirectChatItem db user ct chatItemId ciContent edited live timed_ msgId_ - toView $ CRChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') callStatusItemContent :: User -> Contact -> ChatItemId -> WebRTCCallStatus -> CM (Maybe ACIContent) callStatusItemContent user Contact {contactId} chatItemId receivedStatus = do @@ -480,11 +651,25 @@ receiveFile' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath receiveFile' user ft userApprovedRelays rcvInline_ filePath_ = do (CRRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError where - processError = \case - -- TODO AChatItem in Cancelled events - ChatErrorAgent (SMP _ SMP.AUTH) _ -> pure $ CRRcvFileAcceptedSndCancelled user ft - ChatErrorAgent (CONN DUPLICATE) _ -> pure $ CRRcvFileAcceptedSndCancelled user ft - e -> throwError e + -- TODO AChatItem in Cancelled events + processError e + | rctFileCancelled e = pure $ CRRcvFileAcceptedSndCancelled user ft + | otherwise = throwError e + +receiveFileEvt' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM ChatEvent +receiveFileEvt' user ft userApprovedRelays rcvInline_ filePath_ = do + (CEvtRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError + where + -- TODO AChatItem in Cancelled events + processError e + | rctFileCancelled e = pure $ CEvtRcvFileAcceptedSndCancelled user ft + | otherwise = throwError e + +rctFileCancelled :: ChatError -> Bool +rctFileCancelled = \case + ChatErrorAgent (SMP _ SMP.AUTH) _ -> True + ChatErrorAgent (CONN DUPLICATE) _ -> True + _ -> False acceptFileReceive :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM AChatItem acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = FileInvitation {fileName = fName, fileConnReq, fileInline, fileSize}, fileStatus, grpMemberId, cryptoArgs} userApprovedRelays rcvInline_ filePath_ = do @@ -516,10 +701,10 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI _ -> do chatRef <- withStore $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of - (ChatRef CTDirect contactId, Nothing) -> do + (ChatRef CTDirect contactId _, Nothing) -> do ct <- withStore $ \db -> getContact db vr user contactId acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg - (ChatRef CTGroup groupId, Just memId) -> do + (ChatRef CTGroup groupId _, Just memId) -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId case activeConn of Just conn -> do @@ -591,7 +776,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} aci_ <- resetRcvCIFileStatus user fileId CIFSRcvInvitation forM_ aci_ $ \aci -> do cleanupACIFile aci - toView $ CRChatItemUpdated user aci + toView $ CEvtChatItemUpdated user aci throwChatError $ CEFileNotApproved fileId unknownSrvs cleanupACIFile :: AChatItem -> CM () @@ -645,7 +830,7 @@ startReceivingFile user fileId = do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 getChatItemByFileId db vr user fileId - toView $ CRRcvFileStart user ci + toView $ CEvtRcvFileStart user ci getRcvFilePath :: FileTransferId -> Maybe FilePath -> String -> Bool -> CM FilePath getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of @@ -706,30 +891,32 @@ acceptContactRequest user@User {userId} UserContactRequest {agentInvitationId = dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend (ct,conn,) <$> withAgent (\a -> acceptContact a (aConnId conn) True invId dm pqSup' subMode) -acceptContactRequestAsync :: User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> CM Contact -acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do +acceptContactRequestAsync :: User -> UserContactRequest -> Maybe IncognitoProfile -> PQSupport -> CM Contact +acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile pqSup = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False vr <- chatVersionRange let chatV = vr `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup chatV withStore' $ \db -> do - (ct, Connection {connId}) <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed + (ct, Connection {connId}) <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup True deleteContactRequestRec db user cReq setCommandConnId db user cmdId connId pure ct -acceptGroupJoinRequestAsync :: User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> GroupInfo -> UserContactRequest -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember acceptGroupJoinRequestAsync user gInfo@GroupInfo {groupProfile, membership, businessChat} ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} + gAccepted gLinkMemRole incognitoProfile = do gVar <- asks random + let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted (groupMemberId, memberId) <- withStore $ \db -> do liftIO $ deleteContactRequestRec db user ucr - createAcceptedMember db gVar user gInfo ucr gLinkMemRole + createJoiningMember db gVar user gInfo ucr gLinkMemRole initialStatus currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let Profile {displayName} = profileToSendOnAccept user incognitoProfile True GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -740,6 +927,7 @@ acceptGroupJoinRequestAsync fromMemberName = displayName, invitedMember = MemberIdRole memberId gLinkMemRole, groupProfile, + accepted = Just gAccepted, business = businessChat, groupSize = Just currentMemCount } @@ -748,7 +936,34 @@ acceptGroupJoinRequestAsync let chatV = vr `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do - liftIO $ createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode + liftIO $ createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode + getGroupMemberById db vr user groupMemberId + +acceptGroupJoinSendRejectAsync :: User -> GroupInfo -> UserContactRequest -> GroupRejectionReason -> CM GroupMember +acceptGroupJoinSendRejectAsync + user + gInfo@GroupInfo {groupProfile, membership} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} + rejectionReason = do + gVar <- asks random + (groupMemberId, memberId) <- withStore $ \db -> do + liftIO $ deleteContactRequestRec db user ucr + createJoiningMember db gVar user gInfo ucr GRObserver GSMemRejected + let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + msg = + XGrpLinkReject $ + GroupLinkRejection + { fromMember = MemberIdRole userMemberId userRole, + invitedMember = MemberIdRole memberId GRObserver, + groupProfile, + rejectionReason + } + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` cReqChatVRange + connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV + withStore $ \db -> do + liftIO $ createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode getGroupMemberById db vr user groupMemberId acceptBusinessJoinRequestAsync :: User -> UserContactRequest -> CM GroupInfo @@ -772,6 +987,7 @@ acceptBusinessJoinRequestAsync fromMemberName = displayName, invitedMember = MemberIdRole memberId GRMember, groupProfile = businessGroupProfile userProfile groupPreferences, + accepted = Just GAAccepted, -- This refers to the "title member" that defines the group name and profile. -- This coincides with fromMember to be current user when accepting the connecting user, -- but it will be different when inviting somebody else. @@ -781,15 +997,15 @@ acceptBusinessJoinRequestAsync subMode <- chatReadVar subscriptionMode let chatV = vr `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV - withStore' $ \db -> createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode - let cd = CDGroupSnd gInfo + withStore' $ \db -> createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode + let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo pure gInfo where businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, image} groupPreferences = - GroupProfile {displayName, fullName, description = Nothing, image, groupPreferences = Just groupPreferences} + GroupProfile {displayName, fullName, description = Nothing, image, groupPreferences = Just groupPreferences, memberAdmission = Nothing} profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Bool -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing @@ -798,6 +1014,165 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> NewIncognito p -> p ExistingIncognito lp -> fromLocalProfile lp +introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do + forM_ (memberConn m) $ \mConn -> do + let msg = + if (maxVersion (memberChatVRange m) >= groupKnockingVersion) + then XGrpLinkAcpt GAPendingReview memberRole memberId + else XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing + void $ sendDirectMemberMessage mConn msg groupId + modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs + introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) + +introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceToAll vr user gInfo m = do + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + let recipients = filter memberCurrent members + introduceMember vr user gInfo m recipients Nothing + +introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceToRemaining vr user gInfo m = do + (members, introducedGMIds) <- + withStore' $ \db -> (,) <$> getGroupMembers db vr user gInfo <*> getIntroducedGroupMemberIds db m + let recipients = filter (introduceMemP introducedGMIds) members + introduceMember vr user gInfo m recipients Nothing + where + introduceMemP introducedGMIds mem = + memberCurrent mem + && groupMemberId' mem `notElem` introducedGMIds + && groupMemberId' mem /= groupMemberId' m + +introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () +introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" +introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo m) msgScope + sendIntroductions introduceToMembers + where + sendIntroductions members = do + intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m + shuffledIntros <- liftIO $ shuffleIntros intros + if m `supportsVersion` batchSendVersion + then do + let events = map (memberIntro . reMember) shuffledIntros + forM_ (L.nonEmpty events) $ \events' -> + sendGroupMemberMessages user conn events' groupId + else forM_ shuffledIntros $ \intro -> + processIntro intro `catchChatError` eToView + memberIntro :: GroupMember -> ChatMsgEvent 'Json + memberIntro reMember = + let mInfo = memberInfo reMember + mRestrictions = memberRestrictions reMember + in XGrpMemIntro mInfo mRestrictions + shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] + shuffleIntros intros = do + let (admins, others) = partition isAdmin intros + (admPics, admNoPics) = partition hasPicture admins + (othPics, othNoPics) = partition hasPicture others + mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] + where + isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin + hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image + processIntro intro@GroupMemberIntro {introId} = do + void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId + withStore' $ \db -> updateIntroStatus db introId GMIntroSent + +sendHistory :: User -> GroupInfo -> GroupMember -> CM () +sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" +sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = + when (m `supportsVersion` batchSendVersion) $ do + (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) + (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items + let errors = map ChatErrorStore errs <> errs' + unless (null errors) $ toView $ CEvtChatErrors errors + let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_ + forM_ (L.nonEmpty events') $ \events'' -> + sendGroupMemberMessages user conn events'' groupId + where + descrEvent_ :: Maybe (ChatMsgEvent 'Json) + descrEvent_ + | m `supportsVersion` groupHistoryIncludeWelcomeVersion = do + let GroupInfo {groupProfile = GroupProfile {description}} = gInfo + fmap (\descr -> XMsgNew $ MCSimple $ extMsgContent (MCText descr) Nothing) description + | otherwise = Nothing + itemForwardEvents :: CChatItem 'CTGroup -> CM [ChatMsgEvent 'Json] + itemForwardEvents cci = case cci of + (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) + | not (blockedByAdmin sender) -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender ci mc fInvDescr_ + (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do + fInvDescr_ <- join <$> forM file getSndFileInvDescr + processContentItem membership ci mc fInvDescr_ + _ -> pure [] + where + getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText)) + getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired + then pure Nothing + else do + rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + pure $ invCompleteDescr ciFile rfd + getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText)) + getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired + then pure Nothing + else do + -- can also lookup in extra_xftp_file_descriptions, though it can be empty; + -- would be best if snd file had a single rcv description for all members saved in files table + rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId + pure $ invCompleteDescr ciFile rfd + fileExpired :: CM Bool + fileExpired = do + ttl <- asks $ rcvFilesTTL . agentConfig . config + cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime + pure $ chatItemTs cci < cutoffTs + invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText) + invCompleteDescr CIFile {fileName, fileSize} RcvFileDescr {fileDescrText, fileDescrComplete} + | fileDescrComplete = + let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} + fInv = xftpFileInvitation fileName fileSize fInvDescr + in Just (fInv, fileDescrText) + | otherwise = Nothing + processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] + processContentItem sender ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = + if isNothing fInvDescr_ && not (msgContentHasText mc) + then pure [] + else do + let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta + quotedItemId_ = quoteItemId =<< quotedItem + fInv_ = fst <$> fInvDescr_ + (mc', _, mentions') = updatedMentionNames mc formattedText mentions + mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions' + -- TODO [knocking] send history to other scopes too? + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False + let senderVRange = memberChatVRange' sender + xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} + fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of + (Just fileDescrText, Just msgId) -> do + partSize <- asks $ xftpDescrPartSize . config + let parts = splitFileDescr partSize fileDescrText + pure . L.toList $ L.map (XMsgFileDescr msgId) parts + _ -> pure [] + let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents + GroupMember {memberId} = sender + msgForwardEvents = map (\cm -> XGrpMsgForward memberId cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) + pure msgForwardEvents + +splitFileDescr :: Int -> RcvFileDescrText -> NonEmpty FileDescr +splitFileDescr partSize rfdText = splitParts 1 rfdText + where + splitParts partNo remText = + let (part, rest) = T.splitAt partSize remText + complete = T.null rest + fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} + in if complete + then fileDescr :| [] + else fileDescr <| splitParts (partNo + 1) rest + deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do vr <- chatVersionRange @@ -812,7 +1187,7 @@ deleteGroupLinkIfExists user gInfo = do deleteGroupLink_ :: User -> GroupInfo -> Connection -> CM () deleteGroupLink_ user gInfo conn = do - deleteAgentConnectionAsync user $ aConnId conn + deleteAgentConnectionAsync $ aConnId conn withStore' $ \db -> deleteGroupLink db user gInfo startProximateTimedItemThread :: User -> (ChatRef, ChatItemId) -> UTCTime -> CM () @@ -838,7 +1213,7 @@ startTimedItemThread user itemRef deleteAt = do atomically $ writeTVar threadTVar (Just tId) deleteTimedItem :: User -> (ChatRef, ChatItemId) -> UTCTime -> CM () -deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do +deleteTimedItem user (ChatRef cType chatId scope, itemId) deleteAt = do ts <- liftIO getCurrentTime liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts lift waitChatStartedAndActivated @@ -846,12 +1221,15 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do case cType of CTDirect -> do (ct, ci) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId - deleteDirectCIs user ct [ci] True True >>= toView + deletions <- deleteDirectCIs user ct [ci] + toView $ CEvtChatItemsDeleted user deletions True True CTGroup -> do (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime - deleteGroupCIs user gInfo [ci] True True Nothing deletedTs >>= toView - _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + deletions <- deleteGroupCIs user gInfo chatScopeInfo [ci] Nothing deletedTs + toView $ CEvtChatItemsDeleted user deletions True True + _ -> eToView $ ChatError $ CEInternalError "bad deleteTimedItem cType" startUpdatedTimedItemThread :: User -> ChatRef -> ChatItem c d -> ChatItem c d -> CM () startUpdatedTimedItemThread user chatRef ci ci' = @@ -863,9 +1241,6 @@ startUpdatedTimedItemThread user chatRef ci ci' = metaBrokerTs :: MsgMeta -> UTCTime metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs -sameMemberId :: MemberId -> GroupMember -> Bool -sameMemberId memId GroupMember {memberId} = memId == memberId - createContactPQSndItem :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of @@ -878,7 +1253,7 @@ createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = ct' = ct {activeConn = Just conn'} :: Contact when (contactPQEnabled ct /= contactPQEnabled ct') $ do createInternalChatItem user (CDDirectSnd ct') ciContent Nothing - toView $ CRContactPQEnabled user ct' pqSndEnabled' + toView $ CEvtContactPQEnabled user ct' pqSndEnabled' pure (ct', conn') updateContactPQRcv :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) @@ -894,7 +1269,7 @@ updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' ct' = ct {activeConn = Just conn'} :: Contact when (contactPQEnabled ct /= contactPQEnabled ct') $ do createInternalChatItem user (CDDirectRcv ct') ciContent Nothing - toView $ CRContactPQEnabled user ct' pqRcvEnabled' + toView $ CEvtContactPQEnabled user ct' pqRcvEnabled' pure (ct', conn') updatePeerChatVRange :: Connection -> VersionRangeChat -> CM Connection @@ -968,6 +1343,103 @@ parseChatMessage conn s = do errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) {-# INLINE parseChatMessage #-} +getChatScopeInfo :: VersionRangeChat -> User -> GroupChatScope -> CM GroupChatScopeInfo +getChatScopeInfo vr user = \case + GCSMemberSupport Nothing -> pure $ GCSIMemberSupport Nothing + GCSMemberSupport (Just gmId) -> do + supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId + pure $ GCSIMemberSupport (Just supportMem) + +-- TODO [knocking] refactor to GroupChatScope -> "a" function, "a" is some new type? Or possibly split to get scope/get recipients steps +getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> VersionChat -> CM (Maybe GroupChatScopeInfo, [GroupMember]) +getGroupRecipients vr user gInfo@GroupInfo {membership} scope modsCompatVersion = case scope of + Nothing -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + let recipients = filter memberCurrent ms + pure (Nothing, recipients) + Just (GCSMemberSupport Nothing) -> do + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs + when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" + let scopeInfo = GCSIMemberSupport Nothing + pure (Just scopeInfo, rcpModMs') + Just (GCSMemberSupport (Just gmId)) -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId + unless (memberCurrentOrPending supportMem) $ throwChatError $ CECommandError "support member not current or pending" + let scopeInfo = GCSIMemberSupport (Just supportMem) + if memberStatus supportMem == GSMemPendingApproval + then pure (Just scopeInfo, [supportMem]) + else do + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs + pure (Just scopeInfo, [supportMem] <> rcpModMs') + where + compatible GroupMember {activeConn, memberChatVRange} = + maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= modsCompatVersion + +mkLocalGroupChatScope :: GroupInfo -> CM (GroupInfo, Maybe GroupChatScopeInfo) +mkLocalGroupChatScope gInfo@GroupInfo {membership} + | memberPending membership = do + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo + pure (gInfo', Just scopeInfo) + | otherwise = + pure (gInfo, Nothing) + +mkGroupChatScope :: GroupInfo -> GroupMember -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGroupChatScope gInfo@GroupInfo {membership} m + | memberPending membership = do + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo + pure (gInfo', m, Just scopeInfo) + | memberPending m = do + (m', scopeInfo) <- mkMemberSupportChatInfo m + pure (gInfo, m', Just scopeInfo) + | otherwise = + pure (gInfo, m, Nothing) + +mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = + mkGroupChatScope gInfo m >>= \case + groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope + (_, _, Nothing) -> case msgScope_ of + Nothing -> pure (gInfo, m, Nothing) + Just (MSMember mId) + | sameMemberId mId membership -> do + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo + pure (gInfo', m, Just scopeInfo) + | otherwise -> do + referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId + -- TODO [knocking] return patched _referredMember' too? + (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember + pure (gInfo, m, Just scopeInfo) + +mkGroupSupportChatInfo :: GroupInfo -> CM (GroupInfo, GroupChatScopeInfo) +mkGroupSupportChatInfo gInfo@GroupInfo {membership} = + case supportChat membership of + Nothing -> do + chatTs <- liftIO getCurrentTime + withStore' $ \db -> setSupportChatTs db (groupMemberId' membership) chatTs + let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 0 0 0 Nothing}} + scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} + pure (gInfo', scopeInfo) + Just _supportChat -> + let scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} + in pure (gInfo, scopeInfo) + +mkMemberSupportChatInfo :: GroupMember -> CM (GroupMember, GroupChatScopeInfo) +mkMemberSupportChatInfo m@GroupMember {groupMemberId, supportChat} = + case supportChat of + Nothing -> do + chatTs <- liftIO getCurrentTime + withStore' $ \db -> setSupportChatTs db groupMemberId chatTs + let m' = m {supportChat = Just $ GroupSupportChat chatTs 0 0 0 Nothing} + scopeInfo = GCSIMemberSupport {groupMember_ = Just m'} + pure (m', scopeInfo) + Just _supportChat -> + let scopeInfo = GCSIMemberSupport {groupMember_ = Just m} + in pure (m, scopeInfo) + sendFileChunk :: User -> SndFileTransfer -> CM () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do @@ -979,9 +1451,9 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo liftIO $ updateSndFileStatus db ft FSComplete liftIO $ deleteSndFileChunks db ft updateDirectCIFileStatus db vr user fileId CIFSSndComplete - toView $ CRSndFileComplete user ci ft + toView $ CEvtSndFileComplete user ci ft lift $ closeFileHandle fileId sndFiles - deleteAgentConnectionAsync user acId + deleteAgentConnectionAsync acId sendFileChunkNo :: SndFileTransfer -> Integer -> CM () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do @@ -1029,7 +1501,7 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus, cryptoArgs, fileInvitati removeFile fsFilePath `catchChatError` \_ -> pure () renameFile tmpFile fsFilePath Left e -> do - toView $ CRChatError Nothing e + eToView e removeFile tmpFile `catchChatError` \_ -> pure () withStore' (`removeFileCryptoArgs` fileId) where @@ -1054,7 +1526,7 @@ isFileActive fileId files = do cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM (Maybe ConnId) cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInline} = - cancel' `catchChatError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) + cancel' `catchChatError` (\e -> eToView e $> fileConnId) where cancel' = do lift $ closeFileHandle fileId rcvFiles @@ -1072,13 +1544,13 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInlin cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM [ConnId] cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) - `catchChatError` (toView . CRChatError (Just user)) + `catchChatError` eToView case xftpSndFile of Nothing -> catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) Just xsf -> do forM_ fts (\ft -> cancelSndFileTransfer user ft False) - lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` (toView . CRChatError (Just user)) + lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` eToView pure [] -- TODO v6.0 remove @@ -1086,7 +1558,7 @@ cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM (Maybe ConnId) cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = if fileStatus == FSCancelled || fileStatus == FSComplete then pure Nothing - else cancel' `catchChatError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) + else cancel' `catchChatError` (\e -> eToView e $> fileConnId) where cancel' = do withStore' $ \db -> do @@ -1112,27 +1584,33 @@ deleteMembersConnections user members = deleteMembersConnections' user members F deleteMembersConnections' :: User -> [GroupMember] -> Bool -> CM () deleteMembersConnections' user members waitDelivery = do - let memberConns = - filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ - mapMaybe (\GroupMember {activeConn} -> activeConn) members - deleteAgentConnectionsAsync' user (map aConnId memberConns) waitDelivery - lift . void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns + let memberConns = mapMaybe (\GroupMember {activeConn} -> activeConn) members + deleteAgentConnectionsAsync' (map aConnId memberConns) waitDelivery + lift . void . withStoreBatch' $ \db -> map (\Connection {connId} -> deleteConnectionRecord db user connId) memberConns -deleteMemberConnection :: User -> GroupMember -> CM () -deleteMemberConnection user mem = deleteMemberConnection' user mem False +deleteMemberConnection :: GroupMember -> CM () +deleteMemberConnection mem = deleteMemberConnection' mem False -deleteMemberConnection' :: User -> GroupMember -> Bool -> CM () -deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do +deleteMemberConnection' :: GroupMember -> Bool -> CM () +deleteMemberConnection' GroupMember {activeConn} waitDelivery = do forM_ activeConn $ \conn -> do - deleteAgentConnectionAsync' user (aConnId conn) waitDelivery + deleteAgentConnectionAsync' (aConnId conn) waitDelivery withStore' $ \db -> updateConnectionStatus db conn ConnDeleted -deleteOrUpdateMemberRecord :: User -> GroupMember -> CM () -deleteOrUpdateMemberRecord user@User {userId} member = - withStore' $ \db -> - checkGroupMemberHasItems db user member >>= \case - Just _ -> updateGroupMemberStatus db userId member GSMemRemoved - Nothing -> deleteGroupMember db user member +deleteOrUpdateMemberRecord :: User -> GroupInfo -> GroupMember -> CM GroupInfo +deleteOrUpdateMemberRecord user gInfo member = + withStore' $ \db -> deleteOrUpdateMemberRecordIO db user gInfo member + +deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo +deleteOrUpdateMemberRecordIO db user@User {userId} gInfo member = do + gInfo' <- + if gmRequiresAttention member + then decreaseGroupMembersRequireAttention db user gInfo + else pure gInfo + checkGroupMemberHasItems db user member >>= \case + Just _ -> updateGroupMemberStatus db userId member GSMemRemoved + Nothing -> deleteGroupMember db user member + pure gInfo' sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages user ct events = do @@ -1206,7 +1684,7 @@ sendGroupMemberMessages user conn events groupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) let idsEvts = L.map (GroupId groupId,) events (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs forM_ (L.nonEmpty msgs) $ \msgs' -> batchSendConnMessages user conn MsgFlags {notification = True} msgs' @@ -1219,7 +1697,7 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do let batched_ = batchSndMessagesJSON msgs_ case L.nonEmpty batched_ of Just batched' -> do - let msgReqs = L.map (fmap (msgBatchReq conn msgFlags)) batched' + let msgReqs = L.map (fmap msgBatchReq_) batched' delivered <- deliverMessagesB msgReqs let msgs' = concat $ L.zipWith flattenMsgs batched' delivered pqEnc = findLastPQEnc delivered @@ -1227,6 +1705,9 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do pure (msgs', pqEnc) Nothing -> pure ([], Nothing) where + msgBatchReq_ :: MsgBatch -> ChatMsgReq + msgBatchReq_ (MsgBatch batchBody sndMsgs) = + (conn, msgFlags, (vrValue batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs)) flattenMsgs :: Either ChatError MsgBatch -> Either ChatError ([Int64], PQEncryption) -> [Either ChatError SndMessage] flattenMsgs (Right (MsgBatch _ sndMsgs)) (Right _) = map Right sndMsgs flattenMsgs (Right (MsgBatch _ sndMsgs)) (Left ce) = replicate (length sndMsgs) (Left ce) @@ -1237,9 +1718,6 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do batchSndMessagesJSON :: NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch] batchSndMessagesJSON = batchMessages maxEncodedMsgLength . L.toList -msgBatchReq :: Connection -> MsgFlags -> MsgBatch -> ChatMsgReq -msgBatchReq conn msgFlags (MsgBatch batchBody sndMsgs) = (conn, msgFlags, batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs) - encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo chatMsgEvent = do vr <- chatVersionRange @@ -1265,7 +1743,7 @@ deliverMessage conn cmEventTag msgBody msgId = do deliverMessage' :: Connection -> MsgFlags -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage' conn msgFlags msgBody msgId = - deliverMessages ((conn, msgFlags, msgBody, [msgId]) :| []) >>= \case + deliverMessages ((conn, msgFlags, (vrValue msgBody, [msgId])) :| []) >>= \case r :| [] -> case r of Right ([deliveryId], pqEnc) -> pure (deliveryId, pqEnc) Right (deliveryIds, _) -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 delivery id, got " <> show (length deliveryIds) @@ -1273,45 +1751,45 @@ deliverMessage' conn msgFlags msgBody msgId = rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) -- [MessageId] - SndMessage ids inside MsgBatch, or single message id -type ChatMsgReq = (Connection, MsgFlags, MsgBody, [MessageId]) +type ChatMsgReq = (Connection, MsgFlags, (ValueOrRef MsgBody, [MessageId])) deliverMessages :: NonEmpty ChatMsgReq -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption))) deliverMessages msgs = deliverMessagesB $ L.map Right msgs deliverMessagesB :: NonEmpty (Either ChatError ChatMsgReq) -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption))) deliverMessagesB msgReqs = do - msgReqs' <- liftIO compressBodies + msgReqs' <- if any connSupportsPQ msgReqs then liftIO compressBodies else pure msgReqs sent <- L.zipWith prepareBatch msgReqs' <$> withAgent (`sendMessagesB` snd (mapAccumL toAgent Nothing msgReqs')) lift . void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) lift . withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where + connSupportsPQ = \case + Right (Connection {pqSupport = PQSupportOn, connChatVersion = v}, _, _) -> v >= pqEncryptionCompressionVersion + _ -> False compressBodies = - forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion = v}, msgFlags, msgBody, msgIds) -> - runExceptT $ case pqSupport of - -- we only compress messages when: - -- 1) PQ support is enabled - -- 2) version is compatible with compression - -- 3) message is longer than max compressed size (as this function is not used for batched messages anyway) - PQSupportOn | v >= pqEncryptionCompressionVersion && B.length msgBody > maxCompressedMsgLength -> do + forME msgReqs $ \(conn, msgFlags, (mbr, msgIds)) -> runExceptT $ do + mbr' <- case mbr of + VRValue i msgBody | B.length msgBody > maxCompressedMsgLength -> do let msgBody' = compressedBatchMsgBody_ msgBody when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message" - pure (conn, msgFlags, msgBody', msgIds) - _ -> pure mr + pure $ VRValue i msgBody' + v -> pure v + pure (conn, msgFlags, (mbr', msgIds)) toAgent prev = \case - Right (conn@Connection {connId, pqEncryption}, msgFlags, msgBody, _msgIds) -> + Right (conn@Connection {connId, pqEncryption}, msgFlags, (mbr, _msgIds)) -> let cId = case prev of Just prevId | prevId == connId -> "" _ -> aConnId conn - in (Just connId, Right (cId, pqEncryption, msgFlags, msgBody)) + in (Just connId, Right (cId, pqEncryption, msgFlags, mbr)) Left _ce -> (prev, Left (AP.INTERNAL "ChatError, skip")) -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing createDelivery :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError ([Int64], PQEncryption)) - createDelivery db ((Connection {connId}, _, _, msgIds), (agentMsgId, pqEnc')) = do + createDelivery db ((Connection {connId}, _, (_, msgIds)), (agentMsgId, pqEnc')) = do Right . (,pqEnc') <$> mapM (createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId})) msgIds updatePQSndEnabled :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO () - updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _, _), (_, pqSndEnabled')) = + updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _), (_, pqSndEnabled')) = case (pqSndEnabled, pqSndEnabled') of (Just b, b') | b' /= b -> updatePQ (Nothing, PQEncOn) -> updatePQ @@ -1319,9 +1797,9 @@ deliverMessagesB msgReqs = do where updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' -sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage -sendGroupMessage user gInfo members chatMsgEvent = do - sendGroupMessages user gInfo members (chatMsgEvent :| []) >>= \case +sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage +sendGroupMessage user gInfo gcScope members chatMsgEvent = do + sendGroupMessages user gInfo gcScope members (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message" @@ -1331,15 +1809,17 @@ sendGroupMessage' user gInfo members chatMsgEvent = ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" -sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages user gInfo members events = do +sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages user gInfo scope members events = do + -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ - sendProfileUpdate `catchChatError` (toView . CRChatError (Just user)) + sendProfileUpdate `catchChatError` eToView sendGroupMessages_ user gInfo members events where User {profile = p, userMemberProfileUpdatedAt} = user GroupInfo {userMemberProfileSentAt} = gInfo shouldSendProfileUpdate + | isJust scope = False -- why not sending profile updates to scopes? | incognitoMembership gInfo = False | otherwise = case (userMemberProfileSentAt, userMemberProfileUpdatedAt) of @@ -1360,13 +1840,13 @@ data GroupSndResult = GroupSndResult } sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do +sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do let idsEvts = L.map (GroupId groupId,) events sndMsgs_ <- lift $ createSndMessages idsEvts - recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) + recipientMembers' <- liftIO $ shuffleMembers recipientMembers let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events} (toSendSeparate, toSendBatched, toPending, forwarded, _, dups) = - foldr' addMember ([], [], [], [], S.empty, 0 :: Int) recipientMembers + foldr' (addMember recipientMembers') ([], [], [], [], S.empty, 0 :: Int) recipientMembers' when (dups /= 0) $ logError $ "sendGroupMessages_: " <> tshow dups <> " duplicate members" -- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here -- Deliver to toSend members @@ -1378,7 +1858,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do stored <- lift $ withStoreBatch (\db -> map (bindRight $ createPendingMsg db) pendingReqs) when (length stored /= length pendingMemIds) $ logError "sendGroupMessages_: pendingMemIds and stored length mismatch" -- Zip for easier access to results - let sentTo = zipWith3 (\mId mReq r -> (mId, fmap (\(_, _, _, msgIds) -> msgIds) mReq, r)) sendToMemIds msgReqs delivered + let sentTo = zipWith3 (\mId mReq r -> (mId, fmap (\(_, _, (_, msgIds)) -> msgIds) mReq, r)) sendToMemIds msgReqs delivered pending = zipWith3 (\mId pReq r -> (mId, fmap snd pReq, r)) pendingMemIds pendingReqs stored pure (sndMsgs_, GroupSndResult {sentTo, pending, forwarded}) where @@ -1388,7 +1868,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do liftM2 (<>) (shuffle adminMs) (shuffle otherMs) where isAdmin GroupMember {memberRole} = memberRole >= GRAdmin - addMember m acc@(toSendSeparate, toSendBatched, pending, forwarded, !mIds, !dups) = + addMember members m acc@(toSendSeparate, toSendBatched, pending, forwarded, !mIds, !dups) = case memberSendAction gInfo events members m of Just a | mId `S.member` mIds -> (toSendSeparate, toSendBatched, pending, forwarded, mIds, dups + 1) @@ -1402,24 +1882,36 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do mId = groupMemberId' m mIds' = S.insert mId mIds prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> [(GroupMember, Connection)] -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq]) - prepareMsgReqs msgFlags msgs_ toSendSeparate toSendBatched = do - let batched_ = batchSndMessagesJSON msgs_ + prepareMsgReqs msgFlags msgs toSendSeparate toSendBatched = do + let batched_ = batchSndMessagesJSON msgs case L.nonEmpty batched_ of Just batched' -> do - let (memsSep, mreqsSep) = foldr' foldMsgs ([], []) toSendSeparate - (memsBtch, mreqsBtch) = foldr' (foldBatches batched') ([], []) toSendBatched + let lenMsgs = length msgs + (memsSep, mreqsSep) = foldMembers lenMsgs sndMessageMBR msgs toSendSeparate + (memsBtch, mreqsBtch) = foldMembers (length batched' + lenMsgs) msgBatchMBR batched' toSendBatched (memsSep <> memsBtch, mreqsSep <> mreqsBtch) Nothing -> ([], []) where - foldMsgs :: (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) - foldMsgs (GroupMember {groupMemberId}, conn) memIdsReqs = - foldr' (\msg_ (memIds, reqs) -> (groupMemberId : memIds, fmap sndMessageReq msg_ : reqs)) memIdsReqs msgs_ + foldMembers :: forall a. Int -> (Maybe Int -> Int -> a -> (ValueOrRef MsgBody, [MessageId])) -> NonEmpty (Either ChatError a) -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq]) + foldMembers lastRef mkMb mbs mems = snd $ foldr' foldMsgBodies (lastMemIdx_, ([], [])) mems where - sndMessageReq :: SndMessage -> ChatMsgReq - sndMessageReq SndMessage {msgId, msgBody} = (conn, msgFlags, msgBody, [msgId]) - foldBatches :: NonEmpty (Either ChatError MsgBatch) -> (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) - foldBatches batched' (GroupMember {groupMemberId}, conn) memIdsReqs = - foldr' (\batch_ (memIds, reqs) -> (groupMemberId : memIds, fmap (msgBatchReq conn msgFlags) batch_ : reqs)) memIdsReqs batched' + lastMemIdx_ = let len = length mems in if len > 1 then Just len else Nothing + foldMsgBodies :: (GroupMember, Connection) -> (Maybe Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Maybe Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) + foldMsgBodies (GroupMember {groupMemberId}, conn) (memIdx_, memIdsReqs) = + (subtract 1 <$> memIdx_,) $ snd $ foldr' addBody (lastRef, memIdsReqs) mbs + where + addBody :: Either ChatError a -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) + addBody mb (i, (memIds, reqs)) = + let req = (conn,msgFlags,) . mkMb memIdx_ i <$> mb + in (i - 1, (groupMemberId : memIds, req : reqs)) + sndMessageMBR :: Maybe Int -> Int -> SndMessage -> (ValueOrRef MsgBody, [MessageId]) + sndMessageMBR memIdx_ i SndMessage {msgId, msgBody} = (vrValue_ memIdx_ i msgBody, [msgId]) + msgBatchMBR :: Maybe Int -> Int -> MsgBatch -> (ValueOrRef MsgBody, [MessageId]) + msgBatchMBR memIdx_ i (MsgBatch batchBody sndMsgs) = (vrValue_ memIdx_ i batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs) + vrValue_ memIdx_ i v = case memIdx_ of + Nothing -> VRValue Nothing v -- sending to one member, do not reference bodies + Just 1 -> VRValue (Just i) v + Just _ -> VRRef i preparePending :: NonEmpty (Either ChatError SndMessage) -> [GroupMember] -> ([GroupMemberId], [Either ChatError (GroupMemberId, MessageId)]) preparePending msgs_ = foldr' foldMsgs ([], []) @@ -1437,10 +1929,10 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction -memberSendAction gInfo events members m@GroupMember {memberRole} = case memberConn m of +memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = case memberConn m of Nothing -> pendingOrForwarded Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted -> Nothing + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing | connInactive conn -> Just MSAPending | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn | otherwise -> pendingOrForwarded @@ -1475,10 +1967,10 @@ memberSendAction gInfo events members m@GroupMember {memberRole} = case memberCo XGrpMsgForward {} -> True _ -> False -sendGroupMemberMessage :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () -sendGroupMemberMessage user gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do +sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () +sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) - messageMember msg `catchChatError` (toView . CRChatError (Just user)) + messageMember msg `catchChatError` eToView where messageMember :: SndMessage -> CM () messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case @@ -1542,22 +2034,26 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me if sameMemberId refMemberId am then forM_ (memberConn forwardingMember) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId - else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" throwError e _ -> throwError e saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False +-- TODO [mentions] optimize by avoiding unnecesary parsing of control messages saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd) -saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = - saveSndChatItems user cd [Right NewSndChatItemData {msg, content, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case +saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = do + let itemTexts = ciContentTexts content + saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case [Right ci] -> pure ci _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item" data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, + itemTexts :: (Text, Maybe MarkdownList), + itemMentions :: Map MemberName CIMention, ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), itemForwarded :: Maybe CIForwardedFrom @@ -1574,36 +2070,73 @@ saveSndChatItems :: CM [Either ChatError (ChatItem c 'MDSnd)] saveSndChatItems user cd itemsData itemTimed live = do createdAt <- liftIO getCurrentTime + vr <- chatVersionRange when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - withStore' (\db -> updateChatTs db user cd createdAt) + void $ withStore' (\db -> updateChatTsStats db vr user cd createdAt Nothing) lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) - createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, ciFile, quotedItem, itemForwarded} = do + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ Right $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live createdAt Nothing createdAt + let ci = mkChatItem_ cd ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt + Right <$> case cd of + CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions + _ -> pure ci -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) +saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse + +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = - saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False + saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDRcv) -saveRcvChatItem' user cd msg@RcvMessage {forwardedByMember} sharedMsgId_ brokerTs content ciFile itemTimed live = do +ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) +ciContentNoParse content = (content, (ciContentToText content, Nothing)) + +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem, itemForwarded) <- withStore' $ \db -> do - when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt - r@(ciId, _, _) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt + vr <- chatVersionRange + withStore' $ \db -> do + (mentions' :: Map MemberName CIMention, userMention) <- case cd of + CDGroupRcv g@GroupInfo {membership} _scope _m -> do + mentions' <- getRcvCIMentions db user g ft_ mentions + let userReply = case cmToQuotedMsg chatMsgEvent of + Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership + _ -> False + userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' + in pure (mentions', userMention') + CDDirectRcv _ -> pure (M.empty, False) + cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) + then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) + else pure $ toChatInfo cd + (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure r - pure $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live brokerTs forwardedByMember createdAt + let ci = mkChatItem_ cd ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt + ci' <- case cd of + CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' + _ -> pure ci + pure (ci', cInfo') + where + memberChatStats :: Bool -> Maybe (Int, MemberAttention, Int) + memberChatStats userMention = case cd of + CDGroupRcv _g (Just scope) m -> do + let unread = fromEnum $ ciCreateStatus content == CISRcvNew + in Just (unread, memberAttentionChange unread (Just brokerTs) m scope, fromEnum userMention) + _ -> Nothing -mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live itemTs forwardedByMember currentTs = - let itemText = ciContentToText content - itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs - in ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} +-- TODO [mentions] optimize by avoiding unnecessary parsing +mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d +mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = + let ts = ciContentTexts content + in mkChatItem_ cd ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs + +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d +mkChatItem_ cd ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = + let itemStatus = ciCreateStatus content + meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention currentTs itemTs forwardedByMember currentTs currentTs + in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do @@ -1631,20 +2164,22 @@ agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqSup subMode pure (cmdId, connId) -deleteAgentConnectionAsync :: User -> ConnId -> CM () -deleteAgentConnectionAsync user acId = deleteAgentConnectionAsync' user acId False +deleteAgentConnectionAsync :: ConnId -> CM () +deleteAgentConnectionAsync acId = deleteAgentConnectionAsync' acId False +{-# INLINE deleteAgentConnectionAsync #-} -deleteAgentConnectionAsync' :: User -> ConnId -> Bool -> CM () -deleteAgentConnectionAsync' user acId waitDelivery = do - withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` (toView . CRChatError (Just user)) +deleteAgentConnectionAsync' :: ConnId -> Bool -> CM () +deleteAgentConnectionAsync' acId waitDelivery = do + withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` eToView -deleteAgentConnectionsAsync :: User -> [ConnId] -> CM () -deleteAgentConnectionsAsync user acIds = deleteAgentConnectionsAsync' user acIds False +deleteAgentConnectionsAsync :: [ConnId] -> CM () +deleteAgentConnectionsAsync acIds = deleteAgentConnectionsAsync' acIds False +{-# INLINE deleteAgentConnectionsAsync #-} -deleteAgentConnectionsAsync' :: User -> [ConnId] -> Bool -> CM () -deleteAgentConnectionsAsync' _ [] _ = pure () -deleteAgentConnectionsAsync' user acIds waitDelivery = do - withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` (toView . CRChatError (Just user)) +deleteAgentConnectionsAsync' :: [ConnId] -> Bool -> CM () +deleteAgentConnectionsAsync' [] _ = pure () +deleteAgentConnectionsAsync' acIds waitDelivery = do + withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` eToView agentXFTPDeleteRcvFile :: RcvFileId -> FileTransferId -> CM () agentXFTPDeleteRcvFile aFileId fileId = do @@ -1745,8 +2280,8 @@ createContactsFeatureItems :: createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do let dirsCIContents = map contactChangedFeatures cts (errs, acis) <- partitionEithers <$> createInternalItemsForChats user Nothing dirsCIContents - unless (null errs) $ toView' $ CRChatErrors (Just user) errs - toView' $ CRNewChatItems user acis + unless (null errs) $ toView' $ CEvtChatErrors errs + toView' $ CEvtNewChatItems user acis where contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, [CIContent d]) contactChangedFeatures (Contact {mergedPreferences = cups}, ct'@Contact {mergedPreferences = cups'}) = do @@ -1791,7 +2326,7 @@ createGroupFeatureItems user cd ciContent GroupInfo {fullGroupPreferences} = createInternalChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> CM () createInternalChatItem user cd content itemTs_ = lift (createInternalItemsForChats user itemTs_ [(cd, [content])]) >>= \case - [Right aci] -> toView $ CRNewChatItems user [aci] + [Right aci] -> toView $ CEvtNewChatItems user [aci] [Left e] -> throwError e rs -> throwChatError $ CEInternalError $ "createInternalChatItem: expected 1 result, got " <> show (length rs) @@ -1805,44 +2340,63 @@ createInternalItemsForChats :: createInternalItemsForChats user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ - void . withStoreBatch' $ \db -> map (uncurry $ updateChat db createdAt) dirsCIContents + vr <- chatVersionRange' + void . withStoreBatch' $ \db -> map (uncurry $ updateChat db vr createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (uncurry $ createACIs db itemTs createdAt) dirsCIContents where - updateChat :: DB.Connection -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () - updateChat db createdAt cd contents - | any ciRequiresAttention contents || contactChatDeleted cd = updateChatTs db user cd createdAt + updateChat :: DB.Connection -> VersionRangeChat -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () + updateChat db vr createdAt cd contents + | any ciRequiresAttention contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt memberChatStats | otherwise = pure () + where + memberChatStats :: Maybe (Int, MemberAttention, Int) + memberChatStats = case cd of + CDGroupRcv _g (Just scope) m -> do + let unread = length $ filter ciRequiresAttention contents + in Just (unread, memberAttentionChange unread itemTs_ m scope, 0) + _ -> Nothing createACIs :: DB.Connection -> UTCTime -> UTCTime -> ChatDirection c d -> [CIContent d] -> [IO AChatItem] createACIs db itemTs createdAt cd = map $ \content -> do ciId <- createNewChatItemNoMsg db user cd content itemTs createdAt - let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False itemTs Nothing createdAt + let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci +memberAttentionChange :: Int -> (Maybe UTCTime) -> GroupMember -> GroupChatScopeInfo -> MemberAttention +memberAttentionChange unread brokerTs_ rcvMem = \case + GCSIMemberSupport (Just suppMem) + | groupMemberId' suppMem == groupMemberId' rcvMem -> MAInc unread brokerTs_ + | msgIsNewerThanLastUnanswered -> MAReset + | otherwise -> MAInc 0 Nothing + where + msgIsNewerThanLastUnanswered = case (supportChat suppMem >>= lastMsgFromMemberTs, brokerTs_) of + (Just lastMsgTs, Just brokerTs) -> lastMsgTs < brokerTs + _ -> False + GCSIMemberSupport Nothing -> MAInc 0 Nothing + createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> - [(CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom)] -> + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do - withStore' $ \db -> updateChatTs db user cd createdAt - (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) itemsData) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + vr <- chatVersionRange + void $ withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing + (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) + unless (null errs) $ toView $ CEvtChatErrors errs pure items where - createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom) -> IO (ChatItem 'CTLocal 'MDSnd) - createItem db (content, ciFile, itemForwarded) = do - ciId <- createNewChatItem_ db user cd Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False createdAt Nothing createdAt + createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) + createItem db (content, ciFile, itemForwarded, ts) = do + ciId <- createNewChatItem_ db user cd Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ mkChatItem cd ciId content ciFile Nothing Nothing itemForwarded Nothing False createdAt Nothing createdAt + pure $ mkChatItem_ cd ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False createdAt Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = asks currentUser >>= readTVarIO - >>= maybe (throwChatError CENoActiveUser) run - where - run u = action u `catchChatError` (pure . CRChatCmdError (Just u)) + >>= maybe (throwChatError CENoActiveUser) action withUser :: (User -> CM ChatResponse) -> CM ChatResponse withUser action = withUser' $ \user -> @@ -1896,7 +2450,7 @@ simplexTeamContactProfile = { displayName = "SimpleX Chat team", fullName = "", image = Just (ImageData "data:image/jpg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8KCwkMEQ8SEhEPERATFhwXExQaFRARGCEYGhwdHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAETARMDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD7LooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiivP/iF4yFvv0rSpAZek0yn7v+yPeunC4WpiqihBf8A8rOc5w2UYZ4jEPTourfZDvH3jL7MW03SpR53SWUfw+w96veA/F0erRLY3zKl6owD2k/8Ar15EWLEljknqadDK8MqyxMUdTlWB5Br66WS0Hh/ZLfv1ufiNLj7Mo5m8ZJ3g9OTpy+Xn5/pofRdFcd4B8XR6tEthfMEvVHyk9JB/jXY18fiMPUw9R06i1P3PK80w2aYaOIw8rxf3p9n5hRRRWB6AUUVDe3UFlavc3MixxIMsxppNuyJnOMIuUnZIL26gsrV7m5kWOJBlmNeU+I/Gd9e6sk1hI8FvA2Y1z973NVPGnimfXLoxRFo7JD8if3vc1zefevr8syiNKPtKyvJ9Ox+F8Ycb1cdU+rYCTjTi/iWjk1+nbue3eEPEdtrtoMER3SD95Hn9R7Vu18+6bf3On3kd1aSmOVDkEd/Y17J4P8SW2vWY6R3aD97F/Ue1eVmmVPDP2lP4fyPtODeMoZrBYXFO1Zf+Tf8AB7r5o3qKKK8Q/QgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAqavbTXmmz20Fw1vJIhVZB1FeDa3p15pWoSWl6hWQHr2YeoNfQlY3izw9Z6/YGGZQky8xSgcqf8K9jKcyWEnyzXuv8D4njLhZ51RVSi7VYLRdGu3k+z+88HzRuq1rWmXmkX8lnexFHU8Hsw9RVLNfcxlGcVKLumfgFahUozdOorSWjT6E0M0kMqyxOyOpyrKcEGvXPAPjCPVolsb9wl6owGPAkH+NeO5p8M0kMqyxOyOpyrA4INcWPy+njKfLLfoz2+HuIMTkmI9pT1i/ij0a/wA+zPpGiuM+H/jCPV4lsL91S+QfKTwJR/jXW3t1BZWslzcyLHFGMsxNfB4jC1aFX2U1r+fof0Rl2bYXMMKsVRl7vXy7p9rBfXVvZWr3NzKscSDLMTXjnjbxVPrtyYoiY7JD8if3vc0zxv4ruNeujFEWjsoz8if3vc1zOa+synKFh0qtVe9+X/BPxvjLjKWZSeEwjtSW7/m/4H5kmaM1HmlB54r3bH51YkzXo3wz8MXMc0es3ZeED/VR5wW9z7VB8O/BpnMerarEREDuhhb+L3Pt7V6cAAAAAAOgFfL5xmqs6FH5v9D9a4H4MlzQzHGq1tYR/KT/AEXzCiiivlj9hCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAxfFvh208QWBhmASdRmKUdVP+FeH63pl5pGoSWV5EUdTwezD1HtX0VWL4t8O2fiHTzBONk6g+TKByp/wr28pzZ4WXs6msH+B8NxdwhTzeDxGHVqy/8m8n59n954FmjNW9b0y80fUHsr2MpIp4PZh6iqWfevuYyjOKlF3TPwetQnRm6dRWktGmSwzSQyrLE7I6nKsDgg1teIPFOqa3a29vdy4jiUAheN7f3jWBmjNROhTnJTkrtbGtLF4ijSnRpzajPddHbuP3e9Lmo80ua0scth+a9E+HXgw3Hl6tqsZEX3oYmH3vc+1J8OPBZnKavq0eIhzDCw+9/tH29q9SAAAAGAOgr5bOM35b0KD16v8ARH6twXwXz8uPx0dN4xfXzf6IFAUAAAAdBRRRXyZ+wBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFB4GTXyj+1p+0ONJjufA3ga6DX7qU1DUY24gB4McZH8Xqe38tqFCdefLETaSufQ3h/4geEde8Uah4a0rWra51Ow/wBfCrD8ceuO+OldRX5I+GfEWseG/ENvr2j30ttqFvJ5iSqxyT3z6g96/RH9nD41aT8U9AWGcx2fiK1QC7tC33/+mieqn07V14zL3QXNHVEQnc9dooorzjQKKKKACiis7xHrel+HdGudY1m8is7K2QvLLI2AAP600m3ZAYfxUg8Pr4VutT1+7isYbSMuLp/4Pb3z6V8++HNd0zxDpq6hpVys8DHGRwVPoR2NeIftJ/G7VPifrbWVk8lp4btZD9mtwcGU/wDPR/c9h2rgfh34z1LwdrAurV2ktZCBcW5PyyD/AB9DX2WTyqYWny1Ho+nY+C4t4Wp5tF16CtVX/k3k/Ps/vPr/ADRmsjwx4g07xFpMWpaZOJInHI/iQ9wR61qbq+mVmro/D6tCdGbp1FZrdEma6/4XafpWoa7jUpV3oA0MLdJD/ntXG5p8E0kMqyxOyOhyrKcEGsMTRlWpShGVm+p1ZbiYYPFQr1IKai72fU+nFAUAKAAOABRXEfDnxpFrMK6fqDhL9BhSeko9frXb1+a4rDVMNUdOotT+k8szLD5lh44jDu8X968n5hRRRXOegFFFFABUGoXlvYWkl1dSrHFGMliaL+7t7C0kuruVYoYxlmNeI+OvFtx4huzHFuisYz+7jz97/aNenluW1MbU00it2fM8S8SUMkoXetR/DH9X5fmeteF/E+m+IFkFoxSWMnMb9cev0rbr5t0vULrTb6K8s5TFNGcgj+R9q9w8E+KbXxDYjlY7xB+9i/qPaurNsneE/eUtYfkeTwlxjHNV9XxVo1V90vTz8vmjoqKKK8I+8CiiigAooooAKKKKACiiigD5V/a8+P0mgvdeAvCUskepFdl9eDjyQR9xPfHeviiR3lkaSR2d2OWZjkk+tfoj+058CtP+Jektq2jxRWnie2T91KMKLlR/yzf+h7V+fOuaVqGiarcaXqtpLaXls5jlikXDKRX0mWSpOlaG/U56l76lKtPwtr+reGNetdb0S8ls761cPHJG2D9D6g9MVmUV6TSasyD9Jf2cfjXpPxR0MW9w0dp4gtkAubYnHmf7aeo/lXr1fkh4W1/V/DGuW2taHey2d9bOHjkjP6H1HtX6Jfs5fGvR/inoQgmeOz8RWqD7XaE439vMT1U+navnMfgHRfPD4fyN4Tvoz12iis7xJremeHdEutZ1i7jtLK1jLyyucAAf1rzUm3ZGgeJNb0vw7otzrOs3kVpZWyF5ZZDgAD+Z9q/PL9pP436r8UNZaxs2ks/Dlq5+z24ODMf77+p9B2o/aU+N2p/FDXDZ2LS2fhy1ci3t84Mx/wCej+/oO1eNV9DgMAqS55/F+RhOd9EFFFABJwBkmvUMzqPh34y1Lwjq63FszSWshAntyeHHt719Z2EstzpVlqD2txbR3kCzxLPGUbawyODXK/slfs8nUpbXx144tGFkhElhp8q4849pHB/h9B3r608X+GLDxBpX2WRFiljX9xIowUPYfT2rGnnkMPWVJ6x6vt/XU+P4o4SjmtN4igrVV/5N5Pz7P7z56zRmrmvaVe6LqMljexMkiHg9mHqKoZr6uEozipRd0z8Rq0J0ZunUVmtGmTwTSQTJNC7JIhyrKcEGvZvhz41j1mJdP1GRUv0GFY8CX/69eJZqSCaWCVZYXZHU5VlOCDXDmGXU8bT5ZaPo+x7WQZ9iMlxHtKesX8UejX+fZn1FRXDfDbxtHrUKadqDqmoIuAx4EoHf613NfnWKwtTC1HTqKzR/QGW5lh8yw8cRh3eL+9Ps/MKr6heW1hZyXd3KsUUYyzGjUby20+zku7yZYoY13MzGvDPHvi+48RXpjiZorCM/u4/73+0feuvLMsqY6pZaRW7/AK6nlcScR0MloXetR/DH9X5D/Hni648Q3nlxlo7GM/u48/e9zXL7qZmjNfodDDwoU1TpqyR+AY7G18dXlXryvJ/19w/dVvSdRutMvo7yzlaOVDkY7+xqkDmvTPhn4HMxj1jV4v3Y+aCFh97/AGjWGPxNHDUXKrt27+R15JlWLzHFxp4XSS1v/L53PQ/C+oXGqaJb3t1bNbyyLkoe/v8AQ1p0AAAAAADoBRX5nUkpSbirLsf0lh6c6dKMJy5mkrvv5hRRRUGwUUUUAFFFFABRRRQAV4d+038CdO+JWkyavo8cdp4mtkzHIBhbkD+B/f0Ne40VpSqypSUovUTV9GfkTruk6joer3Ok6taS2d7ayGOaGVdrKRVKv0T/AGnfgXp/xK0h9Y0iOO18TWqZikAwLkD+B/6Gvz51zStQ0TVbjS9UtZbW8tnKSxSLgqRX1GExccRG636o55RcSlWp4V1/VvDGvWut6JeSWl9bOGjkQ4/A+oPpWXRXU0mrMk/RP4LftDeFvF3ge41HxDfW+lappkG+/idsBwP40HfJ7V8o/tJ/G/VPifrbWVk8tn4btn/0e2zgykfxv6n0HavGwSM4JGeuO9JXFRwFKlUc18vIpzbVgoooAJIAGSa7SQr6x/ZM/Z4k1J7Xxz44tClkMSWFhIuDL3Ejg/w+g70fsmfs8NqMtt448c2eLJCJLCwlX/WnqHcH+H0HevtFFVECIoVVGAAMACvFx+PtenTfqzWEOrEjRI41jjUIigBVAwAPSnUUV4ZsYXjLwzZeJNOaCcBLhQfJmA5U/wCFeBa/pV7ompSWF9GUkToccMOxHtX01WF4z8M2XiXTTBOAk6AmGYDlD/hXvZPnEsHL2dTWD/A+K4r4UhmsHXoK1Zf+TeT8+z+8+c80Zq5r2k3ui6jJY30ZSRTwezD1FUM1+gQlGcVKLumfiFWjOjN06is1umTwTSQTJNE7JIh3KynBBr2PwL8QrO701odbnSC5t0yZCcCUD+teK5pd1cWPy2ljoctTdbPqetkme4rJ6rqUHdPdPZ/8Mdb4/wDGFz4ivDFGxisIz+7j/ve5rls1HuozXTQw1PD01TpqyR5+OxlfHV5V68ryf9fcSZozTAa9P+GHgQzmPWdZhIjHzQQMPvf7R9qxxuMpYOk6lR/8E6MpyfEZriFQoL1fRLux/wAMvApmMesazFiP70EDfxf7R9vavWFAUAAAAcACgAAAAAAdBRX5xjsdVxtXnn8l2P3/ACXJcNlGHVGivV9W/wCugUUUVxHrhRRRQAUUUUAFFFFABRRRQAUUUUAFeH/tOfArT/iXpUmsaSsVp4mto/3UuMLcgDhH/oe1e4Vn+I9a0zw7otzrGsXkVpZWyF5ZZGwAB/WtaNSdOalDcTSa1PyZ1zStQ0TVrnStVtZLS8tnMcsUgwVIqlXp/wC0l8S7T4nePn1aw0q3srO3XyYJBGBNOoPDSHv7DtXmFfXU5SlBOSszlYUUUVYAAScDk19Zfsmfs7vqLW3jjx1ZFLMESafYSjmXuJHHZfQd6+VtLvJtO1K2v7cRtLbyrKgkQOpKnIyp4I46Gv0b/Zv+NOjfFDw+lrIIrDX7RAtzZ8AMMffj9V9u1efmVSrCn7m3Vl00m9T16NEjjWONVRFGFUDAA9KWiivmToCiiigAooooAwfGnhiy8S6cYJwEuEH7mYDlT/hXz7r+k32h6lJYahFskQ8Hsw9QfSvpjUr2106ykvLyZYYYxlmY18+/EXxa/ijU1aOMRWkGRCCBuPuT/Svr+GK2KcnTSvT/ACfl/kfmPiBhMvUI1m7Vn0XVefp0fy9Oa3UbqZmjNfa2PynlJM+9AOajzTo5GjkV0YqynIPoaVg5T1P4XeA/P8vWdaiIj+9BAw+9/tH29q9dAAAAAAHQVwPwx8dQ63Ammai6R6hGuFJ4Ew9vf2rvq/Ms5qYmeJaxGjWy6W8j+gOFcPl9LAReBd0931b8+3oFFFFeSfSBRRRQAUUUUAFFFFABRRRQAUUUUAFFFZ3iTW9L8OaJdazrN5HaWNqheWWQ4AH+NNJt2QB4l1vTPDmiXWs6xdx2llaxl5ZHOAAO3ufavzx/aT+N2qfFDWzZWbSWfhy2ci3tg2DKf77+p9B2pf2lfjdqfxQ1trGxeW08N2z/AOj2+cGYj/lo/v6DtXjVfQ4DAKkuefxfkYTnfRBRRQAScAZNeoZhRXv3w2/Zh8V+Lfh7deJprgadcvHv02zlT5rgdcsf4Qe1eHa5pWoaJq1zpWq2ktpeW0hjlikXDKwrOFanUk4xd2htNFKtTwrr+reGNdtta0S8ltL22cPHIhx07H1HtWXRWjSasxH6S/s4/GrSfijoYtp3jtfENqg+1WpON4/vp6j27V69X5IeFfEGr+F9etdc0O9ks7+1cPHKh/QjuD3Ffoj+zl8bNI+KWhLbztFZ+IraMfa7TON+Osieqn07V85j8A6L54fD+RvCd9GevUUUV5hoFVtTvrXTbGW9vJligiXczNRqd9aabYy3t7MsMEQyzMa+ffiN42uvE96YoS0OmxH91F3b/ab3r1spympmFSy0it3+i8z57iDiCjlFG71qPZfq/Id8RPGl14lvTFEzRafGf3cf97/aNclmmZozX6Xh8NTw1NU6askfheNxdbG1pV68ryY/NGTTM16R4J+GVxrGkSX+pSSWfmJ/oq45J7MR6Vni8ZRwkOes7I1y7K8TmNX2WHjd7/0zzvJozV3xDpF7oepyWF/EUkQ8HHDD1FZ+feuiEozipRd0zjq0Z0puE1ZrdE0E8sEyTQu0ciHKspwQa9z+GHjuLXIU0zUpFTUEXCseBKB/WvBs1JBPLBMk0LmORCGVlOCDXn5lllLH0uWWjWz7HsZFnlfJ6/tKesXuu6/z7M+tKK4D4X+PItdhTTNSdY9SQYVicCYDuPf2rv6/M8XhKuEqulVVmj92y7MaGYUFXoO6f4Ps/MKKKK5juCiiigAooooAKKKKACiig9KAM7xLrmleG9EudZ1q8jtLG2QvLK5wAPQep9q/PH9pP43ap8T9beyspJbTw3bSH7NbZx5pH8b+p9u1bH7YPxL8XeJPG114V1G0udH0jT5SIrNuDOR0kbs2e3pXgdfRZfgVTSqT3/IwnO+iCiigAkgAZJr1DMK+s/2TP2d31Brbxz46tNtmMSafp8i8y9/MkB6L0wO9J+yb+zwdSe28b+ObLFmpEljYSr/rT1DuP7voO9faCKqIERQqqMAAYAFeLj8fa9Om/VmsIdWEaJGixooVFGFUDAA9K8Q/ac+BWnfErSZNY0mOO08T2yZilAwtyAPuP/Q9q9worx6VWVKSlF6mrSasfkTrmlahomrXOlaray2l7bSGOaKRcMrCqVfon+098C7D4l6U+s6Skdr4mtY/3UmMC5UdI29/Q1+fOt6XqGi6rcaVqlrJa3ls5SWKQYKkV9RhMXHERut+qOeUeUpVqeFfEGreGNdttb0W7ktb22cNG6HH4H1FZdFdTSasyT9Jf2cPjVpXxR0Fbe4eK18Q2qD7Va7sbx/z0T1H8q9V1O+tdNsZb29mWGCJdzMxr8ovAOoeIdK8W2GoeF5podVhlDQtEefcH2PevsbxP4417xTp1jDq3lQGKFPOigJ2NLj5m59849K4KHD0sTX9x2h18vJHj55xDSyqhd61Hsv1fkaXxG8bXXie9MURaLTo2/dR5+9/tH3rkM1HmjNffYfC08NTVOmrJH4ljMXWxtaVau7yZJmgHmmAmvWfhN8PTceVrmuQkRDDW9uw+9/tN7Vjj8dSwNJ1ar9F3OjK8pr5nXVGivV9Eu7H/Cf4emcx63rkJEfDW9u4+9/tMPT2r2RQFAVQABwAKAAAAAAB0Aor8uzDMKuOq+0qfJdj9zyjKMPlVBUaK9X1bOf8b+FbHxRppt7gCO4UfuZwOUP9R7V86+IdHv8AQtTk0/UIikqHg9mHqD6V9VVz3jnwrY+KNMNvcKEuEBME2OUP+FenkmdywUvZVdab/A8PijheGZw9vQVqq/8AJvJ+fZnzLuo3Ve8Q6Pf6FqclhqERjkQ8Hsw9Qazs1+jwlGpFSi7pn4xVozpTcJqzW6J7eeSCZJoZGjkQhlZTgg17t8LvHsWuQppmpOseooMKxPEw/wAa8DzV3Q7fULvVIIdLWQ3ZcGMx8EH1z2rzs1y2jjaLVTRrZ9v+AezkGcYnK8SpUVzKWjj3/wCD2PrCiqOgx38Oj20eqTJNeLGBK6jAJq9X5VOPLJq9z98pyc4KTVr9H0CiiipLCiiigAooooAKKKKAPK/2hfg3o/xT8PFdsVprlupNnebec/3W9VNfnR4y8Naz4R8RXWg69ZvaXts5V1YcEdmB7g9jX6115V+0P8GtF+Knh05SO0161UmzvQuD/uP6qf0r08DjnRfJP4fyM5wvqj80RycCvrP9kz9ndtRNr458dWTLaAiTT9PlXBl9JJB/d7gd+tXv2bv2Y7yz19vEHxFs1VbKYi1sCQwlZTw7f7PcDvX2CiLGioihVUYAAwAK6cfmGns6T9WTCHVhGiRoqRqFRRgKBgAUtFFeGbBRRRQAV4h+038CtP8AiZpTatpCQ2fia2jPlS4wtyo52P8A0Pavb6K0pVZUpKUXqJq+jPyJ1zStQ0TVrnStVtJbS9tnMcsUgwVIqPS7C61O+isrKFpZ5W2qor9AP2r/AIM6J448OzeJLV7fTtesoyRO3yrcqP4H9/Q14F8OvBlp4XsvMkCTajKP3suM7f8AZX0H86+1yiDzFcy0S3Pms+zqllNLXWb2X6vyH/DnwZaeF7EPIEm1CUDzZcfd/wBke1dfmo80ua+0pUY0oqMVofjWLxNXF1XWrO8mSZozUea9N+B/hTTdau5NUv5opvsrjbak8k9mYelc+OxcMHQlWqbI1y3LqmYYmOHpbvuafwj+HhnMWva5DiMENb27D73ozD09q9oAAAAAAHQCkUBVCqAAOABS1+U5jmNXH1XUqfJdj9yyjKKGV0FRor1fVsKKKK4D1AooooA57xz4UsPFOmG3uFEdwgJgnA5Q/wBR7V84eI9Gv9A1SXT9RhMcqHg/wuOxB7ivrCud8d+E7DxTpZt51CXKDMEwHKn/AAr6LI88lgpeyq603+Hmv1Pj+J+GIZnB16KtVX/k3k/Psz5p0uxu9Tv4rGxheaeVtqIoyTX0T8OPBNp4XsRJKFm1GQfvZf7v+yvtR8OfBFn4UtDIxW41CUfvJsdB/dX0FdfWue568W3RoP3Pz/4BhwvwtHL0sTiVeq9l/L/wQooor5g+3CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKrarf2ml2E19fTpBbwrud2OAKTVdQtNLsJb6+mWGCJcszGvm34nePLzxXfmGEtDpkTfuos/f/wBpvevZyfJ6uZVbLSC3f6LzPBz3PaOVUbvWb2X6vyH/ABM8d3fiq/MULPDpsR/dRdN3+03vXF5pm6jdX6phsLTw1JUqSskfjGLxVbGVnWrO8mSZ96M0wGnSq8UhjkRkdeCrDBFb2OXlFzWn4b1y/wBA1SPUNPmMciHkdmHoR6Vk7hS596ipTjUi4zV0y6c50pqcHZrZn1X4C8W2HizShc27BLmMATwZ5Q/4V0dfIfhvXL/w/qseo6dMY5U6js47gj0r6Y8BeLtP8WaUtzbER3KAefATyh/qPevzPPshlgJe1pa03+Hk/wBGfr/DfEkcygqNbSqv/JvNefdHSUUUV80fWhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFVtVv7TS7CW+vp1ht4l3O7HpSatqNnpWny319OsMES7mZjXzP8UfH154tv8AyYWeDS4WPlQ5xvP95vU/yr2smyarmVWy0gt3+i8zws8zylldK71m9l+r8h/xP8eXfiy/MUJaHTIm/cxZ5b/ab3ris0zNGa/V8NhaWFpKlSVkj8bxeKrYuq61Z3kx+aX2pmTXsnwc+GrXBh8Qa/CViB3W9sw5b0Zh6e1YZhj6OAourVfourfY3y3LK+Y11Ror1fRLux3wc+GxuPK1/X4SIgQ1tbuPvf7TD09BXT/Fv4dQ6/bPqukxpFqca5KgYE4Hb6+9ekKAqhVAAHAApa/L62fYupi1ilKzWy6W7f5n63R4bwVPBPBuN0931v3/AMj4wuIZred4J42jlQlWVhgg0zNfRHxc+HUXiCB9W0mNI9TRcso4EwH9a+eLiKW2neCeNo5UO1kYYIPpX6TlOa0cypc8NJLddv8AgH5XnOS1srrck9YvZ9/+CJmtPw1rl/4f1WLUdPmMcqHkZ4Yeh9qys0Zr0qlONSLhNXTPKpznSmpwdmtmfWHgDxfp/i3SVubZhHcoAJ4CfmQ/1HvXSV8feGdd1Dw9q0WpabMY5UPIz8rr3UjuK+nPAHjDT/FulLcW7CO6QYngJ5Q/1FfmGfZBLAS9rS1pv8PJ/oz9c4c4jjmMFRraVV/5N5rz7o6WiiivmT6wKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAOY+JXhRfFvh5rAXDwTod8LA/KW9GHcV8s65pV/oupzadqNu0FxC2GVu/uPUV9m1x/xM8DWHi/TD8qw6jEP3E4HP+6fUV9Tw7n7wEvY1v4b/AAf+Xc+S4k4eWYR9vR/iL8V29ex8q5o+gq9ruk32i6nLp2oQNFPG2CCOvuPUV6v8Gvhk1w0PiDxDBiH71tbOPvejMPT2r9Cx2Z4fB4f283o9rdfQ/OMBlWIxuI+rwjZre/T1F+DPw0NwYfEPiCDEQ+a2tnH3vRmHp6Cvc1AVQqgADgAUKoVQqgAAYAHalr8lzPMq2Y1nVqv0XRI/YsryuhltBUqS9X1bCiiivOPSCvNfi98OYvEVu+raTEseqRrllHAnHoff3r0qiuvBY2tgqyq0nZr8fJnHjsDRx1F0ayun+Hmj4ruIZbad4J42ilQlWRhgg1Hmvoz4vfDiLxDA+raRGseqRjLIOBOP8a8AsdI1K91hdIgtJDetJ5ZiK4Knvn0xX6zleb0Mwoe1Ts1uu3/A8z8dzbJK+XYj2TV0/hff/g+Q3SbC81XUIbCwgee4mYKiKOpr6a+F3ga28IaaWkYTajOo8+Tsv+yvtTPhd4DtPCWnCWULNqcq/vZcfd/2V9q7avh+IeIHjG6FB/u1u+//AAD73hrhuOBSxGIV6j2X8v8AwQooor5M+xCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAxdd8LaHrd/a32pWKTT2rbo2Pf2PqK2VAVQqgAAYAHalorSVWc4qMm2lt5GcKNOEnKMUm9/MKKKKzNAooooAKKKKACs+HRdLh1iXV4rKFb6VQrzBfmIrQoqozlG/K7XJlCMrOSvYKKKKkoKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA//2Q=="), - contactLink = Just adminContactReq, + contactLink = Just $ CLFull adminContactReq, preferences = Nothing } @@ -1906,7 +2460,7 @@ simplexStatusContactProfile = { displayName = "SimpleX-Status", fullName = "", image = Just (ImageData "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAr6ADAAQAAAABAAAArwAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgArwCvAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQAC//aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Q/v4ooooAKKKKACiiigAoorE8R+ItF8J6Jc+IvEVwlrZ2iGSWWQ4CgVUISlJRirtmdatTo05VaslGMU223ZJLVtvokbdFfl3of/BRbS734rtpup2Ig8LSsIYrjnzkOcea3bafTqBX6cafqFjq1jFqemSrPbzqHjkQ5VlPIINetm2Q43LXD65T5eZXX+XquqPiuC/Efh/itYh5HiVUdGTjJWaflJJ6uEvsy2fqXKKKK8c+5Ciq17e2mnWkl/fyLDDCpd3c4VVHJJJr8c/2kf8Ago34q8M3mpTfByG3fT7CGSJZrlC3nStwJF5GFU8gd69LA5VicXTrVaMfdpxcpPokk397toj4LjvxKyLhGjRqZxValVkowhFc05O9m0tPdjfV7dN2kfq346+J3w9+GWlPrXxA1m00i1QZL3Uqxj8Mnn8K/Mj4tf8ABYD4DeEJ5dM+Gmn3niq4TIE0YEFtn/ffBI+imv51vHfxA8b/ABR1+bxT8RNUuNXvp3LtJcOWCk84VeigdgBXI18LXzupLSkrL72fzrxH9IXNsTKVPKKMaMOkpe/P8fdXpaXqfqvrf/BYH9p6+1w3+iafo1jZA8WrRPKSPeTcpz9BX1l8J/8Ags34PvxDp/xn8M3OmSnAe709hcQfUoSHA/A1/PtSE4/GuKGZ4mLvz39T4TL/ABe4swlZ1ljpTvvGaUo/dbT/ALdsf2rfCX9pT4HfHGzF18M/EdnqTYBaFXCzJn+9G2GH5V7nX8IOm6hqGkX8eraLcy2d3EcpPbuY5FPsykGv6gf+CWf7QPxB+OPwX1Ky+JF22pX3h69+yJdyf62WJlDrvPdlzjPevdwGae3l7OcbP8D+i/DTxm/1ixkcqx2H5K7TalF3jLlV2rPWLtqtWvM/T2iiivYP3c//0f7+KKKKACiiigAooooAK/Fv/goX8Qvi2fFcXgfWrRtP8NDEls0bZS7YfxORxlT0Xt1r9pK8u+L/AMI/Cfxp8F3HgvxbFujlGYpgB5kMg6Op9R+tfR8K5vQy3MYYnE01KK0843+0vNf8NZn5f4wcFZhxTwziMpy3FOjVeqSdo1Lf8u5u11GXk97Xuro/mBFyDX3t+yL+2Be/CW+h8B+OHafw7cyALIxJa0Ldx6p6jt1FfMvx/wDgR4w/Z+8YN4d8RoZrSbLWd4owk6D+TDuK8KF0K/pLFYHA51geWVp0pq6a/Brs1/wH2P8ALvJsz4h4D4h9tR5qGLoS5ZRls11jJbSjJferSi9mf1uafqFlqtlFqWmyrPBOoeORDlWU8gg069vrPTbSS/v5FhghUu7ucKqjqSa/CH9j79sm++EuoQ/D/wAeSNceHbmRVjlZstZk9x6p6jt2q3+15+2fffFS8n8AfD2V7bw9CxWWZThrwj+Se3evxB+G2Zf2n9TX8Lf2nTl/+S/u/PbU/v2P0nuGv9Vf7cf+9/D9Xv73tLd/+ffXn7afF7pqftbfth3nxUu5vAXgGR7fw/A5WWUHDXZX19E9B361+Z/xKm3eCL9R3UfzFbQul6Cn+I/A3ivxR8LPEXivSbVn07RoVkurg8Iu5gAue7HPSv1HOsrwmVcN4uhRSjBUp6vq3Fq7fVt/5I/gTNeI884x4kjmeYOVWtKSdop2hCPvWjFbQjFNv5ybbuz4Toqa0ge9uoLOIhWnkSNSxwAXIUEnsBnmv0+/aK/4Jg+O/gj8Hoviz4b1n/hJFt40l1G2ig2NDG4yZEIJ3KvfgHHNfxVTw9SpGUoK6W5+xZVw1mWZYfEYrA0XOFBKU2raJ31te72b0T0R+XRIAyegr+gr/glx+yZoHhjwBc/tKfFywiafUY2OmpeIGS3sVGWmIbgF+TkjhR71+YP7DX7Lt9+1H8ZLfR75WTw5pBS61ScDKsoIKwg+snf0Ffqd/wAFSv2o4Phf4Ltv2WvhmVtrjUbRBfvA2Ps1kOFhAHQyAc9ML9a9HL6UacHi6q0W3mz9Q8M8owuV4KvxpnEL0aN40Yv/AJeVXpp5LZPo7v7J+M/7U/jX4e/EL4/+JfFXwrsI9P0Ke5K26RKESTZw0oUcAOeQBX7J/wDBFU5+HPjYf9RWH/0SK/nqACgKOgr+hT/giouPh143b11SH/0SKWVzc8YpPrf8jHwexk8XxzSxVRJSn7WTSVknKMnoui7H7a0UUV9cf3Mf/9L+/iiiigAoorzX4wfGD4afAP4bav8AF74v6xbaD4d0K3e6vb26cJHHGgyevUnoAOSeBTjFyajFXYHpVFf55Xxt/wCDu34nj9vzS/G3wX0Qz/ArQ2ksLnSp1CXurQyMA15uPMTqBmJD2+914/uU/Y//AGxfgH+3P8ENL+P37OutxazoWpoNwHyzW02PmhmjPKSKeCD9RxXqY/JcXg4QqV4WUvw8n2ZnCrGTaTPqGiiivKNDy/4u/CLwd8afBtx4N8ZW4kilBMUoH7yGTs6HsR+tfzjftA/AXxl+z54yfw34jQzWkuXs7xF/dzR/0YdxX9OPiDxBofhPQ7vxN4mu4rDT7CF57m4ncJHFFGMszMcAAAZJNf53n/Bav/g5W1H4ufGjTvg5+xB5F14E8JX4l1HVriIE6xNE2GjhLDKQdRuGC55HHX9L8Os+x2ExP1eKcsO/iX8vmvPy6/ifg3jZ4NYDjDBPFUEqeYU17k/50vsT8n0lvF+V0fq0LhTUgnA4r4y/ZG/bJ+FX7YXw9HjDwBP5N/ahV1LTZeJrSUjoR3U/wsOK+sRdL/n/APXX9G0nCrBTpu6Z/mVmuSYvLcXUwOPpOnWg7SjJWaf9ap7NarQ+pf2dP2evGH7Q3i4aLogNvp1uQ15esMpEnoPVj2Ffrd+1V8GvDnw5/YU8X+APh/Z7IrewEjYGXlZGUs7nqSQM18C/sO/ti6b8F7o/Dnx6qpoN9LvS6RRvglbjL45ZT69vpX7wX1poHjjwxNYzbL3TdUt2jbaQySRSrg4PoQa/nnxXxGaTxLwmIjy4e3uW2lpu33Xbp87v+7Po58I8L4nhfFVMuqKeY1oTp1nJe9S5k0oxWtoPfmXxve1uVfwqKA0YHYiv6Ev+CZ37bVv490eP9mb4zXAn1GKJo9Murg5F3bgYMLk9XUcD+8tflR+1/wDsn+Nv2XfiNdadqFs8vh28md9Mv1GY3iJyEY9nXoQa+UrC/v8ASr+DVdJnktbq2dZYZomKvG6nIZSOhFfztQrVMJW1Xqu5+Z8PZ5mvBWeSc4NSg+WrTeinHqv1jL56ptP+s7xHZ/A//gnR8EfE/jTwra+RHqF5JdxWpbLTXcwwkSnrsGPwXNfyrfEDx54l+J/jXU/iB4wna51LVZ3nmdj3Y8KPQKOAPQV2vxX/AGhvjT8corC3+K2vz6vFpq7beNgERT3YqvBY92NeNVeOxirNRpq0Fsju8RePKWfTo4TLqPscFRXuU9F7z+KTSuvJK7srvqwr+ir/AIIuaVd2/wAH/FesSIRDd6uFjb+8Y41Dfka/BX4YfCzx78ZfGVr4C+G+nyajqV22Aqj5I17u7dFUdya/r+/ZV+Aenfs2fBLSPhbZyC4ntVaW7nAx5tzKd0jfTJwPYV1ZLQk63tbaI+w8AOHcXiM8ebcjVClGS5ujlJWUV3sm27baX3R9FUUUV9Uf2gf/0/7+KKKKACv4If8Ag8QT9vN9W8IsVk/4Z+WJedOL7f7Xyd32/HGNu3yc/LnPev73q84+Lnwj+G/x3+HGr/CT4uaRba74d123e1vbK6QPHJG4weD0I6gjkHkV6WUY9YLFQxDgpJdP8vMipDmi0f4W1frt/wAEhP8Agrt8af8AglD8b38V+Fo21zwPr7xp4i0B3KpcRoeJoTyEnjBO04+boeK+m/8AguZ/wQz+I3/BMD4kyfEn4Ww3fiD4Oa5KzWWolC76XKx4tbphwOuI3PDAc81/PdX7LCeFzHC3VpU5f18mjympU5eZ/t9fsk/tb/Av9tv4G6N+0F+z3rUWs6BrEQYFCPNt5cfPDMnVJEPDKf5V794h8Q6F4T0O78TeJ7uGw06wiae4uZ3EcUUaDLMzHAAA6k1/j9f8EiP+Cunxv/4JTfHAeKPCZfWfAuuyRx+IvD8jkRTxg486Lsk8YJ2n+Loa/V7/AILy/wDBxZd/t2eHl/Zc/Y6mu9I+Gl1DDNrWoSBoLvUpGAY2+OqQoeH/AL5GOlfneI4OxCxio0taT+12Xn59u53xxMeW73ND/g4M/wCDgzVP2yNV1H9jz9j3UZrD4ZWE7waxrEDlH110ONiEYItgQe/7z6V/I6AAMDgCgAKNo6Cv0j/4Jkf8Ex/j/wD8FOvj/Y/Cj4UWE9voFvNGdf18xk2um2pPzEt0MhGdiZyTX6FhsNhctwvLH3YR1bfXzfn/AEjhlKVSR77/AMEMf2Rf2v8A9qr9tPRrb9mNpdL0fSp438UaxKjNYW+nk/PHKOA7uoIjTrnniv7Lfj98CvG37PPjiXwj4uiLxNl7S7UYjuIuzD39R1Ffvt+wn+wd+z5/wTy+A+n/AAF/Z70pbKyt1V728cA3V/c4w0079WYnoOijgV7V8cPgb4G+Pngqfwb41twwYEwXCgebBJ2ZT/MdDXi5N4mTwmYWqRvhXpb7S/vL9V28z8c8YfBXC8XYL61hbQx9Ne7LpNfyT8v5ZfZfkfyXi5r9Lf2Jv24bn4S3UHwz+JkzT+HZ5AsNy5LNZlu3vHn8q+KPj38CPHf7PPjabwn4yt2ELMxtLsD91cRg8Mp6Z9R2rxAXAPANfuePyzL89y/2c7TpTV1JdOzT6Nf8Bn8C5FnGfcEZ79Yw96OJpPlnCS0a6xkusX/k4u9mf2IeK/B/w++Mngt9C8U2ltrWi6lEGCuA6OrDhlPY+hHNfztftw/8E4tN+AGlTfE34ba3HJo0koVdMvGC3CFv4Ym/5aAenBArvf2PP2+9R+CGmv4B+JSy6joEUbtaOp3TQOBkRj1Rjx7V8uftEftH+Nf2i/G7+KPEzmG0hyllZqT5cEef1Y9zX4LT8GMTisynhsY7UI6qot5J7Jefe+i87o/prxI8YuEM/wCF6WM+rc2ZSXKo6qVJrdykvih/Ktebsmnb4DkilicxyqVYdQRzXUaN4R1HVMSzjyIf7zDk/QV6dIlpJIJ5Y1Z16MRk1+qf7DX7Ed58ULmH4p/Fe2kt/D8Dq9paSDabwjncf+mf/oX0rKXg3lOR+0zDPMW6lCL92EVyufZN3vfyjbvdI/AeFsJnHFOPp5TktD97L4pP4YLrJu2iXnq3ok20es/8Erv2f/G/gf8AtD4ozj7Bo2pwiFIpY/3t2VOQ4J5VFzx659q/aKq9paWthax2VlGsUMShERBtVVHAAA6AVYr4LNcdTxWIdSjRjSpqyjGKslFber7t6tn+k3APB1LhjJaOUUqsqjjdylJ/FKTvJpfZV9orbzd2yiiivNPsj//U/v4ooooAKKKKAPO/iz8Jvh18c/h1q/wm+LGk2+ueHtdt3tb2yukDxyxuMEEHoR1B6g81/lm/8Fy/+CFfxG/4Jh/ENvid8J4bzxF8Htdmke1vliaRtHctxbXTAEBecRyHAbGDzX+q54j8R6B4Q0C88U+KbyHT9N0+F7i5ubhxHFFFGMszMcAADqa/zM/+Dhb/AIL06p+3f4rvP2Tf2Xr6S0+Eui3DR397GcHXriM8N7W6EfIP4jz6V9fwfPGLFctD+H9q+3/D9jmxKjy+9ufyq0UAY4or9ZPMP0v/AOCX3/BLf9oT/gqP8d4Phf8ACa0lsvDtjLG3iDxDJGTa6bbse56NKwB8uPOSfav9ZX9hD9hT4Df8E8v2fdK/Z7+AenLbWNkoe8vHUfab+6I+eeZhyWY9B0UcCv8AKC/4JUf8FV/j1/wSu+PCfEf4aSHUvC+rPHH4i0CViIL63U43D+7MgJKN+B4r/Wd/Yy/bM+BH7eHwH0j9oL9n7Vo9S0fU4182LI8+0nx88MydVdTxz16ivzbjZ43nipfwOlu/n59uh6GE5Labn1ZRRRXwB2Hi3x3+BPgj9oHwJceCPGcIIYFre4UfvYJezKf5jvX8vH7QvwB8d/s4eOZfB/jKEtDIS9neKP3VxFngqfX1Hav6gvj58e/An7PHgK48ceN7gLtBW2twf3txL2RR/M9hX8rX7Qn7Rnjz9o3x5L418ZyhUXKWlqh/dW8WeFUevqe5r988G4Zu3Ut/ueu/839z/wBu6fM/jj6UdPhlwo8y/wCFTS3Lb+H/ANPf/bPtf9unlQuAec077SPWueFznrTxc1+/eyP4udE/XX9g79h24+K8tv8AF74qQvD4fgkDWdo64N4V53H/AKZg/wDfX0r+ge0tLWwtY7KyjWKGJQiIgwqqOAAOwFfzc/sIft2XnwO1KH4ZfEeVp/Ct5L8k7Es9k7YHH/TMnkjt1r+kDTNT07WtOg1fSJ0ubW5QSRSxncjowyCCOoNfyr4q0s3jmreYfwtfZW+Hl/8Akv5r6/Kx/or9HSXDX+rqhkqtidPb81vac/d/3P5Lab/auXqKKK/Lz+gwooooA//V/v4ooooAKxfEniTQPB2gXnirxVew6dpunQvcXV1cOI4oYoxlndjgAADJJrar/PV/4Ozf+CiX7Xlr8Yrf9hCx0u98GfDaS0iv5L1GZT4iZs5HmKceTERgx9d3LcYr08py2eOxMaEXbu/L9SKk1CN2fIX/AAcD/wDBfrXv27vFF1+yx+ylqFzpnwl0id476+icxSa/MhwGOMEWykHYv8fU9hX8qoAAwOAKUAAYFfqj/wAEnf8AglH8cv8Agqp8ek+Hvw/R9M8I6NJFJ4k19lzHZW7k/ImeGmcAhF/E8V+xUKGFyzC2Xuwju/1fds8tuVSXmM/4JQ/8Epfjr/wVU+Pcfw5+HiPpXhPSXjl8ReIZEJhsoGP3E7PO4B2J+J4r7o/4Li/8EC/H3/BL/UYPjH8Hp7vxV8JNQMcL3sy7rnTLkgDbcFRjZI3KPwATg9q/0rP2MP2MPgL+wZ8BdI/Z5/Z60hNM0bS4x5kpANxeTn7887gAvI55JPToOK9y+J/ww8AfGfwBqvwu+KOlW+t6Brdu9re2V0gkilicYIIP6HqDXwVbjSu8YqlNfulpy9139e3Y7VhY8tnuf4VdfqD/AMErP+Cpvx1/4Jb/ALQNn8S/h7cS6j4VvpUj8QeH2kIt723zgsB0WVRyjetffn/BeH/ghJ4x/wCCZvjlvjP8EYbvXPg5rk7GKcqZJdGmc5FvOwH+rOcRyH0wea/nCr9ApVcNmOGuvehL+vk0cLUqcvM/24v2Mf20PgH+3l8CdK/aA/Z61iPVNI1FF86LI+0Wc+PnhnTqjqeOevUcV3nx/wD2gfh/+zp4CuPHHjq5CBQVtrZT+9uJeyIP5noBX+Ud/wAEL/25f2t/2NP2u7A/s7xPrPhzW5Yk8T6LOzCyls1PzTE9I5UXJRupPHIr+p39o79pXx/+0v8AEGbxv42l2RrlLO0QnyreLPCqPX1PUmvM4b8KauYZg5VJWwkdW/tP+6vPu+i8z8r8VvF3D8L4P6vhbTx017sekF/PL/21fafkjV/aF/aN8e/tHePZ/GvjOc+XuK2lopPlW8WeFUevqe9eFfasDmsL7UB1r9kv+Cen/BPuX4mPa/Gv41Wrw6HE4k0/T5FwbsjkO4PPl56D+L6V/QWbZjlnDmW+1q2hSgrRit2+kYrq/wDh2fw9kXDmdcZ526NK9SvUfNOctorrKT6JdF6JIh/Yq/4JyXXxq8MSfEn4wtPpukXkLLp1vH8s0hYcTHPRR1Ud6+KP2nP2bvHX7MXj+Twl4pUz2U+Xsb5QRHcRZ/Rh/Etf2D2trbWNtHZ2caxRRKEREGFVRwAAOgFeSfHL4G+Af2gvAVz4A8f2wmt5huimUDzYJB0dD2I/Wv5/yrxgx0c3niMcr4abtyL7C6OPdrr/ADeWlv604g+jdlFTh6ngsrfLjaauqj/5eS6xn2i/s2+Hz1v/ABi+d3r9O/2DP28r/wCBGpRfDT4lSvdeFL2UBJmYs9izcZX1j7kduor48/ah/Zr8bfsu/EWTwZ4pHn2c4MtheqMJcQ5IB9mHRhXzd9oAFf0Djsuy3iHLeSdqlGorpr8Gn0a/4DW6P5DyrMc74Mzz2tG9LE0XaUXs11jJdYv/ACaezP7pdK1bTNd02DWdGnS6tLlBJFLEwZHRuQQR1FaFfix/wSG1n47X3hPVLHXUL+BoT/oEtxneLjPzLD6pjr2B6d6/aev424nyP+yMyrZf7RT5Huvv17NdV0Z/pTwPxP8A6w5Lh82dGVJ1FrGXdaNp9YveL6oKKKK8A+sP/9b+/iiiigAr4E/4KI/8E4f2b/8AgpZ8DLr4M/H7SklljV5NJ1aJQLzTblhxLC/Uc43L0YcGvvuitKNadKaqU3aS2Ymk1Zn+Vt8Nf+DZH9vDxJ/wUEn/AGQfGti+m+DdMkF5eeNlTNjLpRb5Xgz964cfL5XVWyTx1/0lv2L/ANif9nv9gn4H6b8Bv2dNDh0jSrFF8+YKDcXs4GGmuJOskjHPJ6dBxX1lgZz3pa9bNc+xWPjGFV2iui6vu/60M6dKMNgooorxTU4T4m/DHwB8ZfAeqfDH4paRba7oGtQPbXtjeRiWGaJxghlII/wr/M//AOCw/wDwbq/En9kb9o7Ttc/ZhQ6h8KvGl4VgknkUyaJIxy0UmTueMDmNgCexr/SN/aA/aA+Hf7N3w6u/iL8RbtYYIFIggBHm3Ev8Mca9yfyA5NfyB/tTftZfEX9qv4gSeL/GEv2exgLJYWEZPlW8WeOO7H+Ju9fsXhRwnmOZYl4hNwwi+Jv7T/lj5930Xnofj3iv4nYThrCPD0bTxs17kekV/PPy7L7T8rn58fs1fs1/Df8AZg8Dp4U8CwB7qYK19fuAZrmQDkseyjsvQV9GfaWrAWcjvUnnt6mv62w+Cp0KapUo2itkfwFmOLxWPxNTGYyo51Zu8pN6t/1stktEftx/wTa/YHsfi6sHx2+L8aT6BFJnT7DcGFy6dWlAzhQf4T171/SBaWltY20dlZRrFDEoREQYVVHAAA6AV/Hv+xJ+3N4y/ZO8Wi0ui+oeE9QkX7dYk5KdjLFzw49Ohr+tj4c/Efwb8WPB1l498A30eoaZqEYkiljOevVWHZh0IPIr+TPGXLs6p5p9Zxz5sO9KbXwxX8rXSXd/a3Wmi/t76P8AmHD08l+qZZDkxUdaydueT/mT0vDsl8Oz1d33FFFFfjR/QB4x8dPgN8O/2hvA1x4F+Idms8MgJhmAxLbydnjbqCP1r8RPg3/wSV8Z/wDC9r7T/izMreDNIlEkM8TYfUVPKpgcoAPv+/Ar+iKivrsh43zbKMLWweCq2hUXXXlf80eza0/HdJnwPFHhpkHEGOw+YZlQ5qlJ7rTnXSM/5op6/hs2jD8NeGdA8HaHbeGvC9nFYWFmgjhghUIiKOwArcoor5Oc5Tk5Sd292fd06cacVCCtFaJLZLsgoooqSz//1/7+KKKKACiiigAooooAK8J/aK/aG+H37M/wzvPiX8QrgRwwDbb26kebczH7saDuSep7DmvdW3bTt69s1/Hj/wAFS9c/acu/2hbiw+Psf2fTYWf+w47bd9ha2zw0ZPWQj7+eQfav0Dw44PpcRZssLXqqFOK5pK9pSS6RXfu+i1PzvxN4zrcN5PLF4ei51JPli7XjFv7U327Lq9Dwr9qv9rn4lftZ+Pv+Ev8AG8i29na7ksNPiJ8m2iJ7Ak5Y/wATHrXy/wDacDJNYfn45PFftR/wTX/4Ju6j8aryz+OXxttpLXwtbSrJY2Mi7W1Bl53MD0hB/wC+vpX9jZpmGU8LZT7WolTo01aMVu30jFdW/wDNvqz+HcryTOeLs4dODdSvUd5Tlsl1lJ9Eui9Elsix/wAE8/8Agmpc/Hq3HxZ+OcFxY+F8f6Daj93Jen++eMiMdum76V88ft4fsM+LP2RvGH9p6MJtS8G6gxNnfMMmFj/yxmIAAYfwnuPev7DbGxs9Ms4tP0+JYIIFCRxoNqqq8AADoBXL+P8AwB4R+KHhG+8C+OrGPUNM1CMxTQyjIIPcehHUEdDX8x4PxqzWOdvH11fDS0dJbKPRp/zrdvrtta39V47wCyWeQRy7D6YqOqrPeUuqkv5Hsl9ndXd7/wACwuGHevvT9iL9u7x1+yP4n+wMDqXhPUJVN/YMTlOxlh/uuB+BqH9vD9hXxl+yD4v/ALS03zNT8HajIfsV8VyYSf8AljNjgMOx/iHvX59C6bHav6fjDKeJsqurVcPVX9ecZRfzTP5LdLOeE850vRxNJ/15SjJfJo/v3+GnxJ8HfF3wRp/xC8BXiX2l6lEJYZEPr1Vh2YdCDyDXd1/PD/wRa8KftJW8moeKfPNp8N7kMBBdKT9ouR/Hbgn5QP4m6Gv6Hq/iHjXh6lkmb1svoVlUjF6Nbq/2ZdOZdbfhsf6AcC8SVs9yahmWIoOlOS1T2dvtR68r3V/x3ZRRRXyh9eFFFFABRRRQB//Q/v4ooooAKKKKACiiigAr5u/aj/Zg+HX7VvwyuPh14+i2N/rLO8jA861mHR0Pp2YdCOK+kaK6sDjq+DxEMVhZuFSDumt00cmOwOHxuHnhcVBTpzVpJ7NM/nF/ZW/4I2eINL+MV9rH7Rk0Vz4d0G5H2GCA8anjlXfuiDjcvJJ46V/RfY2FlpdlFpumxJBbwII444wFVEUYAAHAAFW6K9/injHM+Ia8a+Y1L8qsorSK7tLu3q3+iSPn+E+C8q4dw86GW07czvJvWT7Jvstkv1bYUUUV8sfVnEfEb4c+Dvix4Mv/AAB49sY9Q0vUYjFNDIMjB7j0YdQRyDX4HeH/APgiNJB+0LKNe1vzvhzARcxBeLyUEn/R27ADu46jtmv6KKK+r4d42zjI6Vajl1ZxjUVmt7P+aN9pW0uv0R8lxJwNk2e1aFfMqCnKk7p7XX8srbxvrZ/qzn/CnhXw/wCCPDll4R8K2sdlp2nQrBbwRDCoiDAAFdBRRXy05ynJzm7t6tvqfVwhGEVCCsloktkgoooqSgooooAKKKKAP//R/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Z"), - 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 } @@ -1916,5 +2470,5 @@ timeItToView s action = do a <- action t2 <- liftIO getCurrentTime let diff = diffToMilliseconds $ diffUTCTime t2 t1 - toView' $ CRTimedAction s diff + toView' $ CEvtTimedAction s diff pure a diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 4c95375f73..80e287f414 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -22,22 +22,21 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import qualified Data.Aeson as J -import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (lefts, partitionEithers, rights) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (foldl', partition) -import Data.List.NonEmpty (NonEmpty (..), (<|)) +import Data.List (foldl') +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) -import Data.Time (addUTCTime) -import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime) +import Data.Time.Clock (UTCTime, diffUTCTime) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) @@ -60,14 +59,12 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Description (ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (FilePartyI) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent as Agent -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -92,19 +89,21 @@ smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () -processAgentMessage _ connId (DEL_RCVQ srv qId err_) = - toView $ CRAgentRcvQueueDeleted (AgentConnId connId) srv (AgentQueueId qId) err_ -processAgentMessage _ connId DEL_CONN = - toView $ CRAgentConnDeleted (AgentConnId connId) +processAgentMessage _ _ (DEL_RCVQS delQs) = + toView $ CEvtAgentRcvQueuesDeleted $ L.map rcvQ delQs + where + rcvQ (connId, server, rcvId, err_) = DeletedRcvQueue (AgentConnId connId) server (AgentQueueId rcvId) err_ +processAgentMessage _ _ (DEL_CONNS connIds) = + toView $ CEvtAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = - toView $ CRChatError Nothing $ ChatErrorAgent e Nothing + eToView $ ChatErrorAgent e Nothing processAgentMessage corrId connId msg = do lockEntity <- critical (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do vr <- chatVersionRange -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) + Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. @@ -122,22 +121,22 @@ critical a = processAgentMessageNoConn :: AEvent 'AENone -> CM () processAgentMessageNoConn = \case - CONNECT p h -> hostEvent $ CRHostConnected p h - DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns NSDisconnected CRContactsDisconnected - UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed - SUSPENDED -> toView CRChatSuspended - DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId + CONNECT p h -> hostEvent $ CEvtHostConnected p h + DISCONNECT p h -> hostEvent $ CEvtHostDisconnected p h + DOWN srv conns -> serverEvent srv conns NSDisconnected CEvtContactsDisconnected + UP srv conns -> serverEvent srv conns NSConnected CEvtContactsSubscribed + SUSPENDED -> toView CEvtChatSuspended + DEL_USER agentUserId -> toView $ CEvtAgentUserDeleted agentUserId ERRS cErrs -> errsEvent cErrs where - hostEvent :: ChatResponse -> CM () + hostEvent :: ChatEvent -> CM () hostEvent = whenM (asks $ hostEvents . config) . toView serverEvent srv conns nsStatus event = do chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds ifM (asks $ coreApi . config) (notifyAPI connIds) notifyCLI where connIds = map AgentConnId conns - notifyAPI = toView . CRNetworkStatus nsStatus + notifyAPI = toView . CEvtNetworkStatus nsStatus notifyCLI = do cs <- withStore' (`getConnectionsContacts` conns) toView $ event srv cs @@ -145,7 +144,7 @@ processAgentMessageNoConn = \case errsEvent cErrs = do vr <- chatVersionRange errs <- lift $ rights <$> withStoreBatch' (\db -> map (getChatErr vr db) cErrs) - toView $ CRChatErrors Nothing errs + toView $ CEvtChatErrors errs where getChatErr :: VersionRangeChat -> DB.Connection -> (ConnId, AgentErrorType) -> IO ChatError getChatErr vr db (connId, err) = @@ -157,15 +156,15 @@ processAgentMsgSndFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPSndFileDBIds` AgentSndFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgSndFile" fileId $ withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` (toView . CRChatError (Just user)) + Just user -> process user fileId `catchChatError` eToView _ -> do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId where withEntityLock_ :: Maybe ChatRef -> CM a -> CM a - withEntityLock_ cRef_ = case cRef_ of - Just (ChatRef CTDirect contactId) -> withContactLock "processAgentMsgSndFile" contactId - Just (ChatRef CTGroup groupId) -> withGroupLock "processAgentMsgSndFile" groupId + withEntityLock_ = \case + Just (ChatRef CTDirect contactId _) -> withContactLock "processAgentMsgSndFile" contactId + Just (ChatRef CTGroup groupId _scope) -> withGroupLock "processAgentMsgSndFile" groupId _ -> id process :: User -> FileTransferId -> CM () process user fileId = do @@ -177,7 +176,7 @@ processAgentMsgSndFile _corrId aFileId msg = do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status lookupChatItemByFileId db vr user fileId - toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal + toView $ CEvtSndFileProgressXFTP user ci ft sndProgress sndTotal SFDONE sndDescr rfds -> do withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId @@ -189,18 +188,18 @@ processAgentMsgSndFile _corrId aFileId msg = do [] -> sendFileError (FileErrOther "no receiver descriptions") "no receiver descriptions" vr ft rfd : _ -> case [fd | fd@(FD.ValidFileDescription FD.FileDescription {chunks = [_]}) <- rfds] of [] -> case xftpRedirectFor of - Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft + Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CEvtSndFileRedirectStartXFTP user ft Just _ -> sendFileError (FileErrOther "chaining redirects") "Prohibit chaining redirects" vr ft rfds' -> do -- we have 1 chunk - use it as URI whether it is redirect or not ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor - toView $ CRSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' + toView $ CEvtSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) -> case (msgId_, itemDeleted) of (Just sharedMsgId, Nothing) -> do when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" -- TODO either update database status or move to SFPROG - toView $ CRSndFileProgressXFTP user ci ft 1 1 + toView $ CEvtSndFileProgressXFTP user ci ft 1 1 case (rfds, sfts, d, cInfo) of (rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) @@ -209,11 +208,11 @@ processAgentMsgSndFile _corrId aFileId msg = do Just rs -> case L.last rs of Right ([msgDeliveryId], _) -> withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId - Right (deliveryIds, _) -> toView $ CRChatError (Just user) $ ChatError $ CEInternalError $ "SFDONE, sendFileDescriptions: expected 1 delivery id, got " <> show (length deliveryIds) - Left e -> toView $ CRChatError (Just user) e - Nothing -> toView $ CRChatError (Just user) $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" + Right (deliveryIds, _) -> eToView $ ChatError $ CEInternalError $ "SFDONE, sendFileDescriptions: expected 1 delivery id, got " <> show (length deliveryIds) + Left e -> eToView e + Nothing -> eToView $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) - (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do + (_, _, SMDSnd, GroupChat g@GroupInfo {groupId} _scope) -> do ms <- withStore' $ \db -> getGroupMembers db vr user g let rfdsMemberFTs = zipWith (\rfd (conn, sft) -> (conn, sft, fileDescrText rfd)) rfds (memberFTs ms) extraRFDs = drop (length rfdsMemberFTs) rfds @@ -224,7 +223,7 @@ processAgentMsgSndFile _corrId aFileId msg = do liftIO $ updateCIFileStatus db user fileId CIFSSndComplete getChatItemByFileId db vr user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileCompleteXFTP user ci' ft + toView $ CEvtSndFileCompleteXFTP user ci' ft where memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') @@ -245,7 +244,7 @@ processAgentMsgSndFile _corrId aFileId msg = do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSSndWarning $ agentFileError e) lookupChatItemByFileId db vr user fileId - toView $ CRSndFileWarning user ci ft err + toView $ CEvtSndFileWarning user ci ft err SFERR e -> sendFileError (agentFileError e) (tshow e) vr ft where @@ -260,7 +259,7 @@ processAgentMsgSndFile _corrId aFileId msg = do let (errs, msgReqs) = partitionEithers . L.toList $ L.zipWith (fmap . toMsgReq) connsIdsEvts sndMsgs_ delivered <- mapM deliverMessages (L.nonEmpty msgReqs) let errs' = errs <> maybe [] (lefts . L.toList) delivered - unless (null errs') $ toView $ CRChatErrors (Just user) errs' + unless (null errs') $ toView $ CEvtChatErrors errs' pure delivered where connDescrEvents :: Int -> NonEmpty (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) @@ -271,7 +270,7 @@ processAgentMsgSndFile _corrId aFileId msg = do map (\fileDescr -> (conn, (connOrGroupId, XMsgFileDescr {msgId = sharedMsgId, fileDescr}))) (L.toList $ splitFileDescr partSize rfdText) toMsgReq :: (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq toMsgReq (conn, _) SndMessage {msgId, msgBody} = - (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, msgBody, [msgId]) + (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId])) sendFileError :: FileError -> Text -> VersionRangeChat -> FileTransferMeta -> CM () sendFileError ferr err vr ft = do logError $ "Sent file error: " <> err @@ -279,7 +278,7 @@ processAgentMsgSndFile _corrId aFileId msg = do liftIO $ updateFileCancelled db user fileId (CIFSSndError ferr) lookupChatItemByFileId db vr user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileError user ci ft err + toView $ CEvtSndFileError user ci ft err agentFileError :: AgentErrorType -> FileError agentFileError = \case @@ -294,31 +293,20 @@ agentFileError = \case SMP.TRANSPORT TEVersion -> srvErr SrvErrVersion e -> srvErr . SrvErrOther $ tshow e -splitFileDescr :: Int -> RcvFileDescrText -> NonEmpty FileDescr -splitFileDescr partSize rfdText = splitParts 1 rfdText - where - splitParts partNo remText = - let (part, rest) = T.splitAt partSize remText - complete = T.null rest - fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} - in if complete - then fileDescr :| [] - else fileDescr <| splitParts (partNo + 1) rest - processAgentMsgRcvFile :: ACorrId -> RcvFileId -> AEvent 'AERcvFile -> CM () processAgentMsgRcvFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPRcvFileDBIds` AgentRcvFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgRcvFile" fileId $ withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` (toView . CRChatError (Just user)) + Just user -> process user fileId `catchChatError` eToView _ -> do lift $ withAgent' (`xftpDeleteRcvFile` aFileId) throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId where withEntityLock_ :: Maybe ChatRef -> CM a -> CM a - withEntityLock_ cRef_ = case cRef_ of - Just (ChatRef CTDirect contactId) -> withContactLock "processAgentMsgRcvFile" contactId - Just (ChatRef CTGroup groupId) -> withGroupLock "processAgentMsgRcvFile" groupId + withEntityLock_ = \case + Just (ChatRef CTDirect contactId _) -> withContactLock "processAgentMsgRcvFile" contactId + Just (ChatRef CTGroup groupId _scope) -> withGroupLock "processAgentMsgRcvFile" groupId _ -> id process :: User -> FileTransferId -> CM () process user fileId = do @@ -330,7 +318,7 @@ processAgentMsgRcvFile _corrId aFileId msg = do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status lookupChatItemByFileId db vr user fileId - toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal ft + toView $ CEvtRcvFileProgressXFTP user ci rcvProgress rcvTotal ft RFDONE xftpPath -> case liveRcvFileTransferPath ft of Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file" @@ -343,25 +331,25 @@ processAgentMsgRcvFile _corrId aFileId msg = do updateCIFileStatus db user fileId CIFSRcvComplete lookupChatItemByFileId db vr user fileId agentXFTPDeleteRcvFile aFileId fileId - toView $ maybe (CRRcvStandaloneFileComplete user fsTargetPath ft) (CRRcvFileComplete user) ci_ + toView $ maybe (CEvtRcvStandaloneFileComplete user fsTargetPath ft) (CEvtRcvFileComplete user) ci_ RFWARN e -> do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSRcvWarning $ agentFileError e) lookupChatItemByFileId db vr user fileId - toView $ CRRcvFileWarning user ci e ft + toView $ CEvtRcvFileWarning user ci e ft RFERR e | e == FILE NOT_APPROVED -> do aci_ <- resetRcvCIFileStatus user fileId CIFSRcvAborted forM_ aci_ cleanupACIFile agentXFTPDeleteRcvFile aFileId fileId - forM_ aci_ $ \aci -> toView $ CRChatItemUpdated user aci + forM_ aci_ $ \aci -> toView $ CEvtChatItemUpdated user aci | otherwise -> do aci_ <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ agentFileError e) lookupChatItemByFileId db vr user fileId forM_ aci_ cleanupACIFile agentXFTPDeleteRcvFile aFileId fileId - toView $ CRRcvFileError user aci_ e ft + toView $ CEvtRcvFileError user aci_ e ft processAgentMessageConn :: VersionRangeChat -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do @@ -372,9 +360,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = entity <- critical $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of - RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct - _ -> toView $ CRSubscriptionEnd user entity - MSGNTF msgId msgTs_ -> toView $ CRNtfMessage user entity $ ntfMsgAckInfo msgId msgTs_ + RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct + _ -> toView $ CEvtSubscriptionEnd user entity + MSGNTF msgId msgTs_ -> toView $ CEvtNtfMessage user entity $ ntfMsgAckInfo msgId msgTs_ _ -> case entity of RcvDirectMsgConnection conn contact_ -> processDirectMessage agentMessage entity conn contact_ @@ -450,13 +438,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MWARN _ err -> processConnMWARN connEntity conn err MERR _ err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS _ err -> do -- error cannot be AUTH error here - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -480,11 +468,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e + processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> eToView e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "contact msg=error " <> eInfo <> " " <> tshow e - toView $ CRChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) + eToView (ChatError . CEException $ "error parsing chat message: " <> e) checkSendRcpt ct' $ rights aChatMsgs -- not crucial to use ct'' from processEvent where aChatMsgs = parseChatMessages msgBody @@ -498,7 +486,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live + XMsgUpdate sharedMsgId mContent _ ttl live _msgScope -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile @@ -555,7 +543,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing True allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend - toView $ CRBusinessLinkConnecting user gInfo host ct + toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport @@ -579,9 +567,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) lift $ setContactNetworkStatus ct' NSConnected - toView $ CRContactConnected user ct' (fmap fromLocalProfile incognitoProfile) + toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct') $ do - unless (contactUsed ct') $ withFastStore' $ \db -> updateContactUsed db user ct' createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo pqEnc) Nothing createFeatureEnabledItems ct' when (contactConnInitiated conn') $ do @@ -591,14 +578,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> resetContactConnInitiated db user conn' forM_ viaUserContactLink $ \userContactLinkId -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl + let (UserContactLink {autoAccept}, gli_) = ucl when (connChatVersion < batchSend2Version) $ sendAutoReply ct' autoAccept - forM_ groupId_ $ \groupId -> do + -- TODO REMOVE LEGACY vvv + forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode + -- TODO REMOVE LEGACY ^^^ Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct @@ -612,11 +601,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = cis <- updateDirectItemsStatus' db ct conn msgId (CISSndSent SSPComplete) liftIO $ forM cis $ \ci -> setDirectSndChatItemViaProxy db user ct ci (isJust proxy) let acis = map ctItem cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) SWITCH qd phase cStats -> do - toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) + toView $ CEvtContactSwitch user ct (SwitchProgress qd phase cStats) when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing @@ -639,12 +628,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = . mapM (\(ci, content') -> updateDirectChatItem' db user contactId ci content' False False Nothing Nothing) . mdeUpdatedCI e case ci_ of - Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + Just ci -> toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> do - toView $ CRContactRatchetSync user ct (RatchetSyncProgress rss cStats) + toView $ CEvtContactRatchetSync user ct (RatchetSyncProgress rss cStats) createInternalChatItem user (CDDirectRcv ct) (CIRcvDecryptionError mde n) Nothing ratchetSyncEventItem ct' = do - toView $ CRContactRatchetSync user ct' (RatchetSyncProgress rss cStats) + toView $ CEvtContactRatchetSync user ct' (RatchetSyncProgress rss cStats) createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent $ RCERatchetSync rss) Nothing OK -> -- [async agent commands] continuation on receiving OK @@ -654,10 +643,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> when (directOrUsed ct && sqSecured) $ do lift $ setContactNetworkStatus ct NSConnected - toView $ CRContactSndReady user ct + toView $ CEvtContactSndReady user ct forM_ viaUserContactLink $ \userContactLinkId -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {autoAccept}, _, _) = ucl + let (UserContactLink {autoAccept}, _) = ucl when (connChatVersion >= batchSend2Version) $ sendAutoReply ct autoAccept QCONT -> void $ continueSending connEntity conn @@ -666,14 +655,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processConnMWARN connEntity conn err MERR msgId err -> do updateDirectItemStatus ct conn msgId (CISSndError $ agentSndError err) - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do -- error cannot be AUTH error here updateDirectItemsStatusMsgs ct conn (L.toList msgIds) (CISSndError $ agentSndError err) - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -682,7 +671,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just AutoAccept {autoReply = Just mc} -> do (msg, _) <- sendDirectContactMessage user ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] _ -> pure () processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () @@ -694,7 +683,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] XGrpMemIntro continuation on receiving INV CFCreateConnGrpMemInv | maxVersion (peerChatVRange conn) >= groupDirectInvVersion -> sendWithoutDirectCReq - | otherwise -> sendWithDirectCReq + | otherwise -> messageError "processGroupMessage INV: member chat version range incompatible" where sendWithoutDirectCReq = do let GroupMember {groupMemberId, memberId} = m @@ -702,20 +691,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = liftIO $ setConnConnReqInv db user connId cReq getHostConnId db user groupId sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} - sendWithDirectCReq = do - let GroupMember {groupMemberId, memberId} = m - contData <- withStore' $ \db -> do - setConnConnReqInv db user connId cReq - getXGrpMemIntroContGroup db user m - forM_ contData $ \(hostConnId, directConnReq) -> - sendXGrpMemInv hostConnId (Just directConnReq) XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + -- TODO REMOVE LEGACY vvv -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do ct <- withStore $ \db -> getContactViaMember db vr user m withStore' $ \db -> setNewContactMemberConnRequest db user m cReq groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId - toView $ CRSentGroupInvitation user gInfo ct m + toView $ CEvtSentGroupInvitation user gInfo ct m where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> CM () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do @@ -733,7 +716,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = } (_msg, _) <- sendDirectContactMessage user ct $ XGrpInv groupInv -- we could link chat item with sent group invitation message (_msg) - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + createInternalChatItem user (CDGroupRcv gInfo Nothing m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + -- TODO REMOVE LEGACY ^^^ _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _pqSupport _ connInfo -> do @@ -769,161 +753,84 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO update member profile pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" - XInfo _ -> pure () -- sent when connecting via group link + -- sent when connecting via group link + XInfo _ -> + -- TODO Keep rejected member to allow them to appeal against rejection. + when (memberStatus m == GSMemRejected) $ do + deleteMemberConnection' m True + withStore' $ \db -> deleteGroupMember db user m XOk -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON _pqEnc -> do - withStore' $ \db -> do - updateGroupMemberStatus db userId m GSMemConnected - unless (memberActive membership) $ - updateGroupMemberStatus db userId membership GSMemConnected + CON _pqEnc -> unless (memberStatus m == GSMemRejected || memberStatus membership == GSMemRejected) $ do + -- TODO [knocking] send pending messages after accepting? -- possible improvement: check for each pending message, requires keeping track of connection state unless (connDisabled conn) $ sendPendingGroupMessages user m conn withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do - toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} - let cd = CDGroupRcv gInfo m + (m', gInfo') <- withStore' $ \db -> do + updateGroupMemberStatus db userId m GSMemConnected + gInfo' <- + if not (memberPending membership) + then do + updateGroupMemberStatus db userId membership GSMemConnected + pure gInfo {membership = membership {memberStatus = GSMemConnected}} + else pure gInfo + pure (m {memberStatus = GSMemConnected}, gInfo') + toView $ CEvtUserJoinedGroup user gInfo' m' + (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' + let cd = CDGroupRcv gInfo'' scopeInfo m'' createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing - createGroupFeatureItems user cd CIRcvGroupFeature gInfo - let GroupInfo {groupProfile = GroupProfile {description}} = gInfo - memberConnectedChatItem gInfo m - unless expectHistory $ forM_ description $ groupDescriptionChatItem gInfo m - where - expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion + createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' + memberConnectedChatItem gInfo'' scopeInfo m'' + unless (memberPending membership) $ maybeCreateGroupDescrLocal gInfo'' m'' GCInviteeMember -> do - memberConnectedChatItem gInfo m - toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} + (gInfo', mStatus) <- + if not (memberPending m) + then do + mStatus <- withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected + pure (gInfo, mStatus) + else do + gInfo' <- withStore' $ \db -> increaseGroupMembersRequireAttention db user gInfo + pure (gInfo', memberStatus m) + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + memberConnectedChatItem gInfo'' scopeInfo m' + case scopeInfo of + Just (GCSIMemberSupport _) -> do + createInternalChatItem user (CDGroupRcv gInfo'' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing + _ -> pure () + toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn - when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem - members <- withStore' $ \db -> getGroupMembers db vr user gInfo - void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m - sendIntroductions members - when (groupFeatureAllowed SGFHistory gInfo) sendHistory + when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' when (connChatVersion < batchSend2Version) sendGroupAutoReply + case mStatus of + GSMemPendingApproval -> pure () + GSMemPendingReview -> introduceToModerators vr user gInfo'' m' + _ -> do + introduceToAll vr user gInfo'' m' + when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' where - sendXGrpLinkMem = do - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + sendXGrpLinkMem gInfo'' = do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo'' profileToSend = profileToSendOnAccept user profileMode True void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId - sendIntroductions members = do - intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m - shuffledIntros <- liftIO $ shuffleIntros intros - if m `supportsVersion` batchSendVersion - then do - let events = map (memberIntro . reMember) shuffledIntros - forM_ (L.nonEmpty events) $ \events' -> - sendGroupMemberMessages user conn events' groupId - else forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` (toView . CRChatError (Just user)) - memberIntro :: GroupMember -> ChatMsgEvent 'Json - memberIntro reMember = - let mInfo = memberInfo reMember - mRestrictions = memberRestrictions reMember - in XGrpMemIntro mInfo mRestrictions - shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] - shuffleIntros intros = do - let (admins, others) = partition isAdmin intros - (admPics, admNoPics) = partition hasPicture admins - (othPics, othNoPics) = partition hasPicture others - mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] - where - isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin - hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image - processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId - withStore' $ \db -> updateIntroStatus db introId GMIntroSent - sendHistory = - when (m `supportsVersion` batchSendVersion) $ do - (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) - (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items - let errors = map ChatErrorStore errs <> errs' - unless (null errors) $ toView $ CRChatErrors (Just user) errors - let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_ - forM_ (L.nonEmpty events') $ \events'' -> - sendGroupMemberMessages user conn events'' groupId - descrEvent_ :: Maybe (ChatMsgEvent 'Json) - descrEvent_ - | m `supportsVersion` groupHistoryIncludeWelcomeVersion = do - let GroupInfo {groupProfile = GroupProfile {description}} = gInfo - fmap (\descr -> XMsgNew $ MCSimple $ extMsgContent (MCText descr) Nothing) description - | otherwise = Nothing - itemForwardEvents :: CChatItem 'CTGroup -> CM [ChatMsgEvent 'Json] - itemForwardEvents cci = case cci of - (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) - | not (blockedByAdmin sender) -> do - fInvDescr_ <- join <$> forM file getRcvFileInvDescr - processContentItem sender ci mc fInvDescr_ - (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do - fInvDescr_ <- join <$> forM file getSndFileInvDescr - processContentItem membership ci mc fInvDescr_ - _ -> pure [] - where - getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText)) - getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do - expired <- fileExpired - if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired - then pure Nothing - else do - rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId - pure $ invCompleteDescr ciFile rfd - getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText)) - getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do - expired <- fileExpired - if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired - then pure Nothing - else do - -- can also lookup in extra_xftp_file_descriptions, though it can be empty; - -- would be best if snd file had a single rcv description for all members saved in files table - rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId - pure $ invCompleteDescr ciFile rfd - fileExpired :: CM Bool - fileExpired = do - ttl <- asks $ rcvFilesTTL . agentConfig . config - cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime - pure $ chatItemTs cci < cutoffTs - invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText) - invCompleteDescr CIFile {fileName, fileSize} RcvFileDescr {fileDescrText, fileDescrComplete} - | fileDescrComplete = - let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} - fInv = xftpFileInvitation fileName fileSize fInvDescr - in Just (fInv, fileDescrText) - | otherwise = Nothing - processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] - processContentItem sender ChatItem {meta, quotedItem} mc fInvDescr_ = - if isNothing fInvDescr_ && not (msgContentHasText mc) - then pure [] - else do - let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta - quotedItemId_ = quoteItemId =<< quotedItem - fInv_ = fst <$> fInvDescr_ - (msgContainer, _) <- prepareGroupMsg user gInfo mc quotedItemId_ Nothing fInv_ itemTimed False - let senderVRange = memberChatVRange' sender - xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} - fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of - (Just fileDescrText, Just msgId) -> do - partSize <- asks $ xftpDescrPartSize . config - let parts = splitFileDescr partSize fileDescrText - pure . L.toList $ L.map (XMsgFileDescr msgId) parts - _ -> pure [] - let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents - GroupMember {memberId} = sender - msgForwardEvents = map (\cm -> XGrpMsgForward memberId cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) - pure msgForwardEvents _ -> do + unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected let memCategory = memberCategory m withStore' (\db -> getViaGroupContact db vr user m) >>= \case Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memCategory == GCPreMember) $ probeMatchingMemberContact m connectedIncognito + when (memCategory == GCPreMember) $ + probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn} -> forM_ activeConn $ \Connection {connStatus} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memCategory == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True + when (memCategory == GCPreMember && not (memberPending membership)) $ + probeMatchingContactsAndMembers ct connectedIncognito True sendXGrpMemCon memCategory where GroupMember {memberId} = m @@ -942,55 +849,61 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MSG msgMeta _msgFlags msgBody -> do tags <- newTVarIO [] withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () + -- possible improvement is to choose scope based on event (some events specify scope) + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent tags eInfo chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e + processEvent gInfo' m' tags eInfo chatMsg `catchChatError` \e -> eToView e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e - toView $ CRChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) - forwardMsgs (rights aChatMsgs) `catchChatError` (toView . CRChatError (Just user)) + eToView (ChatError . CEException $ "error parsing chat message: " <> e) + forwardMsgs (rights aChatMsgs) `catchChatError` eToView checkSendRcpt $ rights aChatMsgs where aChatMsgs = parseChatMessages msgBody brokerTs = metaBrokerTs msgMeta - processEvent :: TVar [Text] -> Text -> MsgEncodingI e => ChatMessage e -> CM () - processEvent tags eInfo chatMsg@ChatMessage {chatMsgEvent} = do + processEvent :: GroupInfo -> GroupMember -> TVar [Text] -> Text -> MsgEncodingI e => ChatMessage e -> CM () + processEvent gInfo' m' tags eInfo chatMsg@ChatMessage {chatMsgEvent} = do let tag = toCMEventTag chatMsgEvent atomically $ modifyTVar' tags (tshow tag :) logInfo $ "group msg=" <> tshow tag <> " " <> eInfo - (m', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m conn msgMeta msgBody chatMsg + (m'', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta msgBody chatMsg case event of - XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs False - XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg brokerTs ttl live - XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg brokerTs - XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg brokerTs + XMsgNew mc -> memberCanSend m'' scope $ newGroupContentMessage gInfo' m'' mc msg brokerTs False + where ExtMsgContent {scope} = mcExtMsgContent mc + -- file description is always allowed, to allow sending files to support scope + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live + XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo' m'' sharedMsgId memberId msg brokerTs + XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId reaction add msg brokerTs -- TODO discontinue XFile - XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg brokerTs - XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName - XInfo p -> xInfoMember gInfo m' p brokerTs - XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p - XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg brokerTs - XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo m' memInfo memRestrictions_ - XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv - XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m' memInfo introInv - XGrpMemRole memId memRole -> xGrpMemRole gInfo m' memId memRole msg brokerTs - XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo m' memId memRestrictions msg brokerTs - XGrpMemCon memId -> xGrpMemCon gInfo m' memId - XGrpMemDel memId -> xGrpMemDel gInfo m' memId msg brokerTs - XGrpLeave -> xGrpLeave gInfo m' msg brokerTs - XGrpDel -> xGrpDel gInfo m' msg brokerTs - XGrpInfo p' -> xGrpInfo gInfo m' p' msg brokerTs - XGrpPrefs ps' -> xGrpPrefs gInfo m' ps' - XGrpDirectInv connReq mContent_ -> memberCanSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg brokerTs - XGrpMsgForward memberId msg' msgTs -> xGrpMsgForward gInfo m' memberId msg' msgTs - XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash - XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe - BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta + XFile fInv -> processGroupFileInvitation' gInfo' m'' fInv msg brokerTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' m'' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName + XInfo p -> xInfoMember gInfo' m'' p brokerTs + XGrpLinkMem p -> xGrpLinkMem gInfo' m'' conn' p + XGrpLinkAcpt acceptance role memberId -> xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs + XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo' m'' memInfo memRestrictions_ + XGrpMemInv memId introInv -> xGrpMemInv gInfo' m'' memId introInv + XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo' m'' memInfo introInv + XGrpMemRole memId memRole -> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs + XGrpMemCon memId -> xGrpMemCon gInfo' m'' memId + XGrpMemDel memId withMessages -> xGrpMemDel gInfo' m'' memId withMessages msg brokerTs + XGrpLeave -> xGrpLeave gInfo' m'' msg brokerTs + XGrpDel -> xGrpDel gInfo' m'' msg brokerTs + XGrpInfo p' -> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpPrefs ps' -> xGrpPrefs gInfo' m'' ps' + -- TODO [knocking] why don't we forward these messages? + XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs + XGrpMsgForward memberId msg' msgTs -> xGrpMsgForward gInfo' m'' memberId msg' msgTs + XInfoProbe probe -> xInfoProbe (COMGroupMember m'') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m'') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m'') probe + BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> tshow event checkSendRcpt :: [AChatMessage] -> CM Bool checkSendRcpt aMsgs = do @@ -1005,6 +918,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = hasDeliveryReceipt (toCMEventTag chatMsgEvent) forwardMsgs :: [AChatMessage] -> CM () forwardMsgs aMsgs = do + -- TODO [knocking] forward to/from GSMemPendingReview members let GroupMember {memberRole = membershipMemRole} = membership when (membershipMemRole >= GRAdmin && not (blockedByAdmin m)) $ do let forwardedMsgs = mapMaybe (\(ACMsg _ chatMsg) -> forwardedGroupMsg chatMsg) aMsgs @@ -1020,7 +934,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let GroupMember {memberId} = m ms = forwardedToGroupMembers (introducedMembers <> invitedMembers) forwardedMsgs' events = L.map (\cm -> XGrpMsgForward memberId cm brokerTs) forwardedMsgs' - unless (null ms) $ void $ sendGroupMessages user gInfo ms events + unless (null ms) $ void $ sendGroupMessages_ user gInfo ms events RCVD msgMeta msgRcpt -> withAckMessage' "group rcvd" agentConnId msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -1031,37 +945,39 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy) when continued $ sendPendingGroupMessages user m conn SWITCH qd phase cStats -> do - toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) + toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m when (phase == SPStarted || phase == SPCompleted) $ case qd of - QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing - QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing - RSYNC rss cryptoErr_ cStats -> + QDRcv -> createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m') Nothing + QDSnd -> createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvConnEvent $ RCESwitchQueue phase) Nothing + RSYNC rss cryptoErr_ cStats -> do + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m case (rss, connectionCode, cryptoErr_) of - (RSRequired, _, Just cryptoErr) -> processErr cryptoErr - (RSAllowed, _, Just cryptoErr) -> processErr cryptoErr + (RSRequired, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr + (RSAllowed, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr (RSAgreed, Just _, _) -> do withStore' $ \db -> setConnectionVerified db user connId Nothing - let m' = m {activeConn = Just (conn {connectionCode = Nothing} :: Connection)} :: GroupMember - ratchetSyncEventItem m' - toView $ CRGroupMemberVerificationReset user gInfo m' - createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent RCEVerificationCodeReset) Nothing - _ -> ratchetSyncEventItem m + let m'' = m' {activeConn = Just (conn {connectionCode = Nothing} :: Connection)} :: GroupMember + ratchetSyncEventItem gInfo' scopeInfo m'' + toViewTE $ TEGroupMemberVerificationReset user gInfo' m'' + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') (CIRcvConnEvent RCEVerificationCodeReset) Nothing + _ -> ratchetSyncEventItem gInfo' scopeInfo m' where - processErr cryptoErr = do + processErr gInfo' scopeInfo m' cryptoErr = do let e@(mde, n) = agentMsgDecryptError cryptoErr ci_ <- withStore $ \db -> - getGroupMemberChatItemLast db user groupId (groupMemberId' m) + getGroupMemberChatItemLast db user groupId (groupMemberId' m') >>= liftIO . mapM (\(ci, content') -> updateGroupChatItem db user groupId ci content' False False Nothing) . mdeUpdatedCI e case ci_ of - Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) + Just ci -> toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo' scopeInfo) ci) _ -> do - toView $ CRGroupMemberRatchetSync user gInfo m (RatchetSyncProgress rss cStats) - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvDecryptionError mde n) Nothing - ratchetSyncEventItem m' = do - toView $ CRGroupMemberRatchetSync user gInfo m' (RatchetSyncProgress rss cStats) - createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent $ RCERatchetSync rss) Nothing + toView $ CEvtGroupMemberRatchetSync user gInfo' m' (RatchetSyncProgress rss cStats) + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvDecryptionError mde n) Nothing + ratchetSyncEventItem gInfo' scopeInfo m' = do + toView $ CEvtGroupMemberRatchetSync user gInfo' m' (RatchetSyncProgress rss cStats) + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvConnEvent $ RCERatchetSync rss) Nothing OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () @@ -1078,16 +994,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MERR msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSError $ agentSndError err) -- group errors are silenced to reduce load on UI event log - -- toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + -- eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do let newStatus = GSSError $ agentSndError err -- error cannot be AUTH error here withStore' $ \db -> forM_ msgIds $ \msgId -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure () - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1111,9 +1027,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing send mc = do msg <- sendGroupMessage' user gInfo [m] (XMsgNew $ MCSimple (extMsgContent mc Nothing)) - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) + ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndMsgContent mc) withStore' $ \db -> createGroupSndStatus db (chatItemId' ci) (groupMemberId' m) GSSNew - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing) ci] agentMsgDecryptError :: AgentCryptoError -> (MsgDecryptError, Word32) agentMsgDecryptError = \case @@ -1158,21 +1074,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSConnected updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 - toView $ CRSndFileStart user ci ft + toView $ CEvtSndFileStart user ci ft sendFileChunk user ft SENT msgId _proxy -> do withStore' $ \db -> updateSndFileChunkSent db ft msgId unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do - cancelSndFileTransfer user ft True >>= mapM_ (deleteAgentConnectionAsync user) + cancelSndFileTransfer user ft True >>= mapM_ deleteAgentConnectionAsync case err of SMP _ SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> do liftIO (lookupChatRefByFileId db user fileId) >>= \case - Just (ChatRef CTDirect _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled + Just (ChatRef CTDirect _ _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled _ -> pure () lookupChatItemByFileId db vr user fileId - toView $ CRSndFileRcvCancelled user ci ft + toView $ CEvtSndFileRcvCancelled user ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ _ -> withAckMessage' "file msg" agentConnId meta $ pure () @@ -1183,7 +1099,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1232,10 +1148,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () MERR _ err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1244,9 +1160,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync ci <- withStore $ \db -> getChatItemByFileId db vr user fileId - toView $ CRRcvFileSndCancelled user ci ft + toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -1269,8 +1185,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateCIFileStatus db user fileId CIFSRcvComplete deleteRcvFileChunks db ft getChatItemByFileId db vr user fileId - toView $ CRRcvFileComplete user ci - forM_ conn_ $ \conn -> deleteAgentConnectionAsync user (aConnId conn) + toView $ CEvtRcvFileComplete user ci + mapM_ (deleteAgentConnectionAsync . aConnId) conn_ RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo @@ -1284,58 +1200,67 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () where profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> CM () - profileContactRequest invId chatVRange p xContactId_ reqPQSup = do + profileContactRequest invId chatVRange p@Profile {displayName} xContactId_ reqPQSup = do withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case - CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact - CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo + CORContact contact -> toView $ CEvtContactRequestAlreadyAccepted user contact + CORGroup gInfo -> toView $ CEvtBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {connReqContact, autoAccept}, groupId_, gLinkMemRole) = 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} | businessAddress -> - if v < groupFastLinkJoinVersion || (isSimplexTeam && v < businessChatsVersion) + if isSimplexTeam && v < businessChatsVersion then do - ct <- acceptContactRequestAsync user cReq Nothing True reqPQSup - toView $ CRAcceptingContactRequest user ct + ct <- acceptContactRequestAsync user cReq Nothing reqPQSup + toView $ CEvtAcceptingContactRequest user ct else do gInfo <- acceptBusinessJoinRequestAsync user cReq - toView $ CRAcceptingBusinessRequest user gInfo - | otherwise -> case groupId_ of + toView $ CEvtAcceptingBusinessRequest user gInfo + | otherwise -> case gLinkInfo_ of Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequestAsync user cReq incognitoProfile True reqPQSup - toView $ CRAcceptingContactRequest user ct - Just groupId -> do + ct <- acceptContactRequestAsync user cReq incognitoProfile reqPQSup + toView $ CEvtAcceptingContactRequest user ct + Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if v >= groupFastLinkJoinVersion - then do - mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode - createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else do - -- TODO v5.7 remove old API (or v6.0?) - ct <- acceptContactRequestAsync user cReq profileMode False PQSupportOff - toView $ CRAcceptingGroupJoinRequest user gInfo ct - _ -> toView $ CRReceivedContactRequest user cReq + acceptMember_ <- asks $ acceptMember . chatHooks . config + maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case + Right (acceptance, useRole) + | v < groupFastLinkJoinVersion -> + messageError "processUserContactRequest: chat version range incompatible for accepting group join request" + | otherwise -> do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + mem <- acceptGroupJoinRequestAsync user gInfo cReq acceptance useRole profileMode + (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' + Left rjctReason + | v < groupJoinRejectVersion -> + messageWarning $ "processUserContactRequest (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" + | otherwise -> do + mem <- acceptGroupJoinSendRejectAsync user gInfo cReq rjctReason + toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason + _ -> toView $ CEvtReceivedContactRequest user cReq - memberCanSend :: GroupMember -> CM () -> CM () - memberCanSend GroupMember {memberRole} a - | memberRole <= GRObserver = messageError "member is not allowed to send messages" - | otherwise = a + memberCanSend :: GroupMember -> Maybe MsgScope -> CM () -> CM () + memberCanSend m@GroupMember {memberRole} msgScope a = case msgScope of + Just MSMember {} -> a + Nothing + | memberRole > GRObserver || memberPending m -> a + | otherwise -> messageError "member is not allowed to send messages" processConnMERR :: ConnectionEntity -> Connection -> AgentErrorType -> CM () processConnMERR connEntity conn err = do @@ -1344,12 +1269,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = authErrCounter' <- withStore' $ \db -> incAuthErrCounter db user conn when (authErrCounter' >= authErrDisableCount) $ case connEntity of RcvDirectMsgConnection ctConn (Just ct) -> do - toView $ CRContactDisabled user ct {activeConn = Just ctConn {authErrCounter = authErrCounter'}} - _ -> toView $ CRConnectionDisabled connEntity + toView $ CEvtContactDisabled user ct {activeConn = Just ctConn {authErrCounter = authErrCounter'}} + _ -> toView $ CEvtConnectionDisabled connEntity SMP _ SMP.QUOTA -> unless (connInactive conn) $ do withStore' $ \db -> setQuotaErrCounter db user conn quotaErrSetOnMERR - toView $ CRConnectionInactive connEntity True + toView $ CEvtConnectionInactive connEntity True _ -> pure () processConnMWARN :: ConnectionEntity -> Connection -> AgentErrorType -> CM () @@ -1359,8 +1284,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (connInactive conn) $ do quotaErrCounter' <- withStore' $ \db -> incQuotaErrCounter db user conn when (quotaErrCounter' >= quotaErrInactiveCount) $ - toView $ - CRConnectionInactive connEntity True + toView $ CEvtConnectionInactive connEntity True _ -> pure () continueSending :: ConnectionEntity -> Connection -> CM Bool @@ -1368,7 +1292,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if connInactive conn then do withStore' $ \db -> setQuotaErrCounter db user conn 0 - toView $ CRConnectionInactive connEntity False + toView $ CEvtConnectionInactive connEntity False pure True else pure False @@ -1456,23 +1380,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = badRcvFileChunk :: RcvFileTransfer -> String -> CM () badRcvFileChunk ft err = unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync throwChatError $ CEFileRcvChunk err - memberConnectedChatItem :: GroupInfo -> GroupMember -> CM () - memberConnectedChatItem gInfo m = + memberConnectedChatItem :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> CM () + memberConnectedChatItem gInfo scopeInfo m = -- ts should be broker ts but we don't have it for CON - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing - - groupDescriptionChatItem :: GroupInfo -> GroupMember -> Text -> CM () - groupDescriptionChatItem gInfo m descr = - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing + createInternalChatItem user (CDGroupRcv gInfo scopeInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> CM () notifyMemberConnected gInfo m ct_ = do - memberConnectedChatItem gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + memberConnectedChatItem gInfo' scopeInfo m' lift $ mapM_ (`setContactNetworkStatus` NSConnected) ct_ - toView $ CRConnectedToGroupMember user gInfo m ct_ + toView $ CEvtConnectedToGroupMember user gInfo' m' ct_ probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> CM () probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do @@ -1529,15 +1450,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> CM () - messageWarning = toView . CRMessageError user "warning" + messageWarning = toView . CEvtMessageError user "warning" messageError :: Text -> CM () - messageError = toView . CRMessageError user "error" + messageError = toView . CEvtMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () - newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do - unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct - let ExtMsgContent content fInv_ _ _ = mcExtMsgContent mc + newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do + let ExtMsgContent content _ fInv_ _ _ _ = mcExtMsgContent mc -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete -- case content of -- MCText "hello 111" -> @@ -1546,26 +1466,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- _ -> pure () if isVoice content && not (featureAllowed SCFVoice forContact ct) then do - void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False + void $ newChatItem (ciContentNoParse $ CIRcvChatFeatureRejected CFVoice) Nothing Nothing False else do - let ExtMsgContent _ _ itemTTL live_ = mcExtMsgContent mc + let ExtMsgContent _ _ _ itemTTL live_ _ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct - newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content, msgContentTexts content) (snd <$> file_) timed_ live autoAcceptFile file_ where brokerTs = metaBrokerTs msgMeta - newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live + newChatItem content ciFile_ timed_ live = do + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci {reactions}] autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> CM () autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do -- ! autoAcceptFileSize is only used in tests ChatConfig {autoAcceptFileSize = sz} <- asks config - when (sz > fileSize) $ receiveFile' user ft False Nothing Nothing >>= toView + when (sz > fileSize) $ receiveFileEvt' user ft False Nothing Nothing >>= toView messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> CM () messageFileDescription ct@Contact {contactId} sharedMsgId fileDescr = do @@ -1575,7 +1495,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM () groupMessageFileDescription g@GroupInfo {groupId} m sharedMsgId fileDescr = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - processFDMessage (CDGroupRcv g m) sharedMsgId fileId fileDescr + -- here scope we pass only affects how chat item is searched in getAChatItemBySharedMsgId, and it ignores scope + processFDMessage (CDGroupRcv g Nothing m) sharedMsgId fileId fileDescr processFDMessage :: ChatTypeQuotable c => ChatDirection c 'MDRcv -> SharedMsgId -> FileTransferId -> FileDescr -> CM () processFDMessage cd sharedMsgId fileId fileDescr = do @@ -1589,7 +1510,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (rfd, ft') when fileDescrComplete $ do ci <- withStore $ \db -> getAChatItemBySharedMsgId db user cd sharedMsgId - toView $ CRRcvFileDescrReady user ci ft' rfd + toView $ CEvtRcvFileDescrReady user ci ft' rfd case (fileStatus, xftpRcvFile) of (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd userApprovedRelays cryptoArgs _ -> pure () @@ -1623,11 +1544,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs content Nothing timed_ live + ts = ciContentTexts content + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content True live Nothing Nothing - toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv cInfo ci') where brokerTs = metaBrokerTs msgMeta content = CIRcvMsgContent mc @@ -1646,24 +1568,25 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- getDirectCIReactions db ct sharedMsgId let edited = itemLive /= Just True updateDirectChatItem' db user contactId ci {reactions} content edited live Nothing $ Just msgId - toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' - else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + startUpdatedTimedItemThread user (ChatRef CTDirect contactId Nothing) ci ci' + else toView $ CEvtChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> messageError "x.msg.update: contact attempted invalid message update" messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> CM () messageDelete ct@Contact {contactId} sharedMsgId _rcvMessage msgMeta = do - deleteRcvChatItem `catchCINotFound` (toView . CRChatItemDeletedNotFound user ct) + deleteRcvChatItem `catchCINotFound` (toView . CEvtChatItemDeletedNotFound user ct) where brokerTs = metaBrokerTs msgMeta deleteRcvChatItem = do cci@(CChatItem msgDir ci) <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId case msgDir of SMDRcv - | rcvItemDeletable ci brokerTs -> - if featureAllowed SCFFullDelete forContact ct - then deleteDirectCIs user ct [cci] False False >>= toView - else markDirectCIsDeleted user ct [cci] False brokerTs >>= toView + | rcvItemDeletable ci brokerTs -> do + deletions <- if featureAllowed SCFFullDelete forContact ct + then deleteDirectCIs user ct [cci] + else markDirectCIsDeleted user ct [cci] brokerTs + toView $ CEvtChatItemsDeleted user deletions False False | otherwise -> messageError "x.msg.del: contact attempted invalid message delete" SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" @@ -1681,7 +1604,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setDirectReaction db ct sharedMsgId False reaction add msgId brokerTs where updateChatItemReaction = do - cr_ <- withStore $ \db -> do + cEvt_ <- withStore $ \db -> do CChatItem md ci <- getDirectChatItemBySharedMsgId db user (contactId' ct) sharedMsgId if ciReactionAllowed ci then liftIO $ do @@ -1689,12 +1612,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- getDirectCIReactions db ct sharedMsgId let ci' = CChatItem md ci {reactions} r = ACIReaction SCTDirect SMDRcv (DirectChat ct) $ CIReaction CIDirectRcv ci' brokerTs reaction - pure $ Just $ CRChatItemReaction user add r + pure $ Just $ CEvtChatItemReaction user add r else pure Nothing - mapM_ toView cr_ + mapM_ toView cEvt_ groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM () - groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} brokerTs = do + groupMsgReaction g m sharedMsgId itemMemberId reaction add RcvMessage {msgId} brokerTs = do when (groupFeatureAllowed SGFReactions g) $ do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False when (reactionAllowed add reaction rs) $ do @@ -1702,17 +1625,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs where updateChatItemReaction = do - cr_ <- withStore $ \db -> do - CChatItem md ci <- getGroupMemberCIBySharedMsgId db user groupId itemMemberId sharedMsgId + cEvt_ <- withStore $ \db -> do + CChatItem md ci <- getGroupMemberCIBySharedMsgId db user g itemMemberId sharedMsgId + scopeInfo <- getGroupChatScopeInfoForItem db vr user g (chatItemId' ci) if ciReactionAllowed ci then liftIO $ do setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs reactions <- getGroupCIReactions db g itemMemberId sharedMsgId let ci' = CChatItem md ci {reactions} - r = ACIReaction SCTGroup SMDRcv (GroupChat g) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction - pure $ Just $ CRChatItemReaction user add r + r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction + pure $ Just $ CEvtChatItemReaction user add r else pure Nothing - mapM_ toView cr_ + mapM_ toView cEvt_ reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool reactionAllowed add reaction rs = (reaction `elem` rs) /= add && not (add && length rs >= maxMsgReactions) @@ -1724,59 +1648,67 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = e -> throwError e newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM () - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded - | blockedByAdmin m = createBlockedByAdmin - | otherwise = case prohibitedGroupContent gInfo m content fInv_ of - Just f -> rejected f - Nothing -> - withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration ciModeration - withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ - Nothing -> createContentItem + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + if blockedByAdmin m' + then createBlockedByAdmin gInfo' m' scopeInfo + else + case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of + Just f -> rejected gInfo' m' scopeInfo f + Nothing -> + withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration gInfo' m' scopeInfo ciModeration + withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ + Nothing -> createContentItem gInfo' m' scopeInfo where - rejected f = void $ newChatItem (CIRcvGroupFeatureRejected f) Nothing Nothing False - timed' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo itemTTL + rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False + timed' gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL live' = fromMaybe False live_ - ExtMsgContent content fInv_ itemTTL live_ = mcExtMsgContent mc - createBlockedByAdmin - | groupFeatureAllowed SGFFullDelete gInfo = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvBlocked Nothing timed' False - ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs - groupMsgToView gInfo ci' + ExtMsgContent content mentions fInv_ itemTTL live_ msgScope_ = mcExtMsgContent mc + ts@(_, ft_) = msgContentTexts content + saveRcvCI gInfo' m' scopeInfo = saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg sharedMsgId_ brokerTs + createBlockedByAdmin gInfo' m' scopeInfo + | groupFeatureAllowed SGFFullDelete gInfo' = do + -- ignores member role when blocked by admin + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvBlocked) Nothing (timed' gInfo') False M.empty + ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo' ci brokerTs + groupMsgToView cInfo ci' | otherwise = do - file_ <- processFileInv - ci <- createNonLive file_ - ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci - groupMsgToView gInfo ci' - applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} + file_ <- processFileInv m' + (ci, cInfo) <- createNonLive gInfo' m' scopeInfo file_ + ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo' ci + groupMsgToView cInfo ci' + applyModeration gInfo' m' scopeInfo CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = - createContentItem - | groupFeatureAllowed SGFFullDelete gInfo = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False - ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt - groupMsgToView gInfo ci' + createContentItem gInfo' m' scopeInfo + | groupFeatureMemberAllowed SGFFullDelete moderator gInfo' = do + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvModerated) Nothing (timed' gInfo') False M.empty + ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo' ci moderator moderatedAt + groupMsgToView cInfo ci' | otherwise = do - file_ <- processFileInv - ci <- createNonLive file_ - toView =<< markGroupCIsDeleted user gInfo [CChatItem SMDRcv ci] False (Just moderator) moderatedAt - createNonLive file_ = - saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content) (snd <$> file_) timed' False - createContentItem = do - file_ <- processFileInv - newChatItem (CIRcvMsgContent content) (snd <$> file_) timed' live' - when (showMessages $ memberSettings m) $ autoAcceptFile file_ - processFileInv = - processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m - newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live - ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo ci' {reactions} + file_ <- processFileInv m' + (ci, _cInfo) <- createNonLive gInfo' m' scopeInfo file_ + deletions <- markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt + toView $ CEvtChatItemsDeleted user deletions False False + createNonLive gInfo' m' scopeInfo file_ = do + saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') False mentions + createContentItem gInfo' m' scopeInfo = do + file_ <- processFileInv m' + newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') live' + when (showMessages $ memberSettings m') $ autoAcceptFile file_ + processFileInv m' = + processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m' + newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed_ live = do + let mentions' = if showMessages (memberSettings m') then mentions else [] + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed_ live mentions' + ci' <- blockedMember m' ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo' ci + reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ + groupMsgToView cInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} brokerTs ttl_ live_ - | prohibitedSimplexLinks gInfo m mc = + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ + | prohibitedSimplexLinks gInfo m ft_ = messageWarning $ "x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks | otherwise = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -1784,17 +1716,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) brokerTs content Nothing timed_ live + mentions' = if showMessages (memberSettings m) then mentions else [] + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc ci' <- updateGroupChatItem db user groupId ci content True live Nothing - blockedMember m ci' $ markGroupChatItemBlocked db user gInfo ci' - toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') + blockedMember m' ci' $ markGroupChatItemBlocked db user gInfo' ci' + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') where content = CIRcvMsgContent mc + ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do - cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user gInfo groupMemberId sharedMsgId + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) case cci of CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> if sameMemberId memberId m' @@ -1807,29 +1743,31 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) reactions <- getGroupCIReactions db gInfo memberId sharedMsgId let edited = itemLive /= Just True - updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId - toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) + ciMentions <- getRcvCIMentions db user gInfo ft_ mentions + ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + updateGroupCIMentions db gInfo ci' ciMentions + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') + startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' + else toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) else messageError "x.msg.update: group member attempted to update a message of another member" _ -> messageError "x.msg.update: group member attempted invalid message update" groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> UTCTime -> CM () - groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} brokerTs = do + groupMessageDelete gInfo@GroupInfo {membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} brokerTs = do let msgMemberId = fromMaybe memberId sndMemberId_ - withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo msgMemberId sharedMsgId) >>= \case Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of CIGroupRcv mem -> case sndMemberId_ of -- regular deletion Nothing | sameMemberId memberId mem && msgMemberId == memberId && rcvItemDeletable ci brokerTs -> - delete cci Nothing >>= toView + delete cci Nothing | otherwise -> messageError "x.msg.del: member attempted invalid message delete" -- moderation (not limited by time) Just _ | sameMemberId memberId mem && msgMemberId == memberId -> - delete cci (Just m) >>= toView + delete cci (Just m) | otherwise -> moderate mem cci CIGroupSnd -> moderate membership cci @@ -1842,7 +1780,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = moderate mem cci = case sndMemberId_ of Just sndMemberId | sameMemberId sndMemberId mem -> checkRole mem $ do - delete cci (Just m) >>= toView + delete cci (Just m) archiveMessageReports cci m | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" _ -> messageError "x.msg.del: message of another member without memberId" @@ -1850,14 +1788,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a - delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse - delete cci byGroupMember - | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs - | otherwise = markGroupCIsDeleted user gInfo [cci] False byGroupMember brokerTs + delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM () + delete cci byGroupMember = do + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) + deletions <- if groupFeatureMemberAllowed SGFFullDelete m gInfo + then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs + else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs + toView $ CEvtChatItemsDeleted user deletions False False archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs - unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) + unless (null ciIds) $ toView $ CEvtGroupChatItemsDeleted user gInfo ciIds False (Just byMember) -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> CM () @@ -1868,8 +1809,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] where brokerTs = metaBrokerTs msgMeta @@ -1881,9 +1823,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False + content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" + (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - groupMsgToView gInfo ci' + groupMsgToView cInfo ci' blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) blockedMember m ci blockedCI @@ -1904,9 +1847,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync ci <- withStore $ \db -> getChatItemByFileId db vr user fileId - toView $ CRRcvFileSndCancelled user ci ft + toView $ CEvtRcvFileSndCancelled user ci ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> CM () xFileAcptInv ct sharedMsgId fileConnReq_ fName = do @@ -1928,7 +1871,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- createSndDirectInlineFT db ct ft - pure $ CRSndFileStart user ci' sft + pure $ CEvtSndFileStart user ci' sft toView event ifM (allowSendInline fileSize fileInline) @@ -1958,8 +1901,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case file of Just CIFile {fileProtocol = FPXFTP} -> do ft <- withStore $ \db -> getFileTransferMeta db user fileId - toView $ CRSndFileCompleteXFTP user ci ft - _ -> toView $ CRSndFileComplete user ci sft + toView $ CEvtSndFileCompleteXFTP user ci ft + _ -> toView $ CEvtSndFileComplete user ci sft allowSendInline :: Integer -> Maybe InlineFileMode -> CM Bool allowSendInline fileSize = \case @@ -1989,18 +1932,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = receiveFileChunk ft Nothing meta chunk xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM () - xFileCancelGroup GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do + xFileCancelGroup g@GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user g groupMemberId sharedMsgId case (msgDir, chatDir) of (SMDRcv, CIGroupRcv m) -> do if sameMemberId memberId m then do ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync ci <- withStore $ \db -> getChatItemByFileId db vr user fileId - toView $ CRRcvFileSndCancelled user ci ft + toView $ CEvtRcvFileSndCancelled user ci ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" @@ -2025,7 +1968,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- liftIO $ createSndGroupInlineFT db m conn ft - pure $ CRSndFileStart user ci' sft + pure $ CEvtSndFileStart user ci' sft toView event ifM (allowSendInline fileSize fileInline) @@ -2034,9 +1977,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" - groupMsgToView :: forall d. MsgDirectionI d => GroupInfo -> ChatItem 'CTGroup d -> CM () - groupMsgToView gInfo ci = - toView $ CRNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo) ci] + groupMsgToView :: forall d. MsgDirectionI d => ChatInfo 'CTGroup -> ChatItem 'CTGroup d -> CM () + groupMsgToView cInfo ci = do + toView $ CEvtNewChatItems user [AChatItem SCTGroup (msgDirection @d) cInfo ci] processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> CM () processGroupInvitation ct inv msg msgMeta = do @@ -2058,13 +2001,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) + toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItem user (CDDirectRcv ct) msg brokerTs content + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] - toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] + toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where brokerTs = metaBrokerTs msgMeta sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool @@ -2085,16 +2028,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted contactConns <- withStore' $ \db -> getContactConnections db vr userId ct' - deleteAgentConnectionsAsync user $ map aConnId contactConns + deleteAgentConnectionsAsync $ map aConnId contactConns forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} let ct'' = ct' {activeConn = activeConn'} :: Contact - ci <- saveRcvChatItem user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci] - toView $ CRContactDeletedByContact user ct'' + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] + toView $ CEvtContactDeletedByContact user ct'' else do contactConns <- withStore' $ \db -> getContactConnections db vr userId c - deleteAgentConnectionsAsync user $ map aConnId contactConns + deleteAgentConnectionsAsync $ map aConnId contactConns withStore $ \db -> deleteContact db user c where brokerTs = metaBrokerTs msgMeta @@ -2111,7 +2054,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (directOrUsed c' && createItems) $ do createProfileUpdatedItem c' lift $ createRcvFeatureItems user c c' - toView $ CRContactUpdated user c c' + toView $ CEvtContactUpdated user c c' pure c' | otherwise = pure c @@ -2154,6 +2097,62 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatchingMemberContact m' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" + xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () + xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs + | sameMemberId memberId membership = processUserAccepted + | otherwise = + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case + Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" + Right referencedMember -> do + (referencedMember', gInfo') <- withFastStore' $ \db -> do + referencedMember' <- updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role + gInfo' <- updateGroupMembersRequireAttention db user gInfo referencedMember referencedMember' + pure (referencedMember', gInfo') + when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' + -- create item in both scopes + memberConnectedChatItem gInfo' Nothing referencedMember' + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Just referencedMember'} + gEvent = RGEMemberAccepted (groupMemberId' referencedMember') (fromLocalProfile $ memberProfile referencedMember') + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci + toView $ CEvtMemberAcceptedByOther user gInfo' m referencedMember' + where + newMemberStatus refMem = case memberConn refMem of + Just c | connReady c -> GSMemConnected + _ -> GSMemAnnounced + where + processUserAccepted = case acceptance of + GAAccepted -> do + membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role + -- create item in both scopes + let gInfo' = gInfo {membership = membership'} + cd = CDGroupRcv gInfo' Nothing m + createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing + createGroupFeatureItems user cd CIRcvGroupFeature gInfo' + maybeCreateGroupDescrLocal gInfo' m + createInternalChatItem user cd (CIRcvGroupEvent RGEUserAccepted) Nothing + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGEUserAccepted) Nothing + toView $ CEvtUserJoinedGroup user gInfo' m + GAPendingReview -> do + membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemPendingReview role + let gInfo' = gInfo {membership = membership'} + scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent SGEUserPendingReview) Nothing + toView $ CEvtMemberAcceptedByOther user gInfo' m membership' + GAPendingApproval -> + messageWarning "x.grp.link.acpt: unexpected group acceptance - pending approval" + introduceToRemainingMembers acceptedMember = do + introduceToRemaining vr user gInfo acceptedMember + when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember + + maybeCreateGroupDescrLocal :: GroupInfo -> GroupMember -> CM () + maybeCreateGroupDescrLocal gInfo@GroupInfo {groupProfile = GroupProfile {description}} m = + unless expectHistory $ forM_ description $ \descr -> + createInternalChatItem user (CDGroupRcv gInfo Nothing m) (CIRcvMsgContent $ MCText descr) Nothing + where + expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_ | redactedMemberProfile (fromLocalProfile p) /= redactedMemberProfile p' = do @@ -2162,7 +2161,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> do m' <- withStore $ \db -> updateMemberProfile db user m p' createProfileUpdatedItem m' - toView $ CRGroupMemberUpdated user gInfo m m' + toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db vr user mContactId @@ -2170,8 +2169,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' createProfileUpdatedItem m' - toView $ CRGroupMemberUpdated user gInfo m m' - toView $ CRContactUpdated user mCt ct' + toView $ CEvtGroupMemberUpdated user gInfo m m' + toView $ CEvtContactUpdated user mCt ct' pure m' else pure m where @@ -2186,15 +2185,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' - toView $ CRGroupUpdated user g g' (Just m) + toView $ CEvtGroupUpdated user g g' (Just m) _ -> pure () isMainBusinessMember BusinessChatInfo {chatType, businessId, customerId} GroupMember {memberId} = case chatType of BCBusiness -> businessId == memberId BCCustomer -> customerId == memberId createProfileUpdatedItem m' = when createItems $ do + (gInfo', m'', scopeInfo) <- mkGroupChatScope gInfo m' let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p' - createInternalChatItem user (CDGroupRcv gInfo m') ciContent itemTs_ + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') ciContent itemTs_ createFeatureEnabledItems :: Contact -> CM () createFeatureEnabledItems ct@Contact {mergedPreferences} = @@ -2281,7 +2281,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do g <- asks random dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing - ci <- saveCallItem CISCallPending + (ci, cInfo) <- saveCallItem CISCallPending callUUID <- UUID.toText <$> liftIO V4.nextRandom let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} @@ -2293,15 +2293,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci} - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci} + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] else featureRejected CFCalls where brokerTs = metaBrokerTs msgMeta - saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) + saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvChatFeatureRejected f) Nothing Nothing False - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + let content = ciContentNoParse $ CIRcvChatFeatureRejected f + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] -- to party initiating call xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> CM () @@ -2312,7 +2313,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey) callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} askConfirmation = encryptedCall localCallType && not (encryptedCall callType) - toView CRCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} + toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) _ -> do msgCallStateError "x.call.offer" call @@ -2325,7 +2326,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = \call -> case callState call of CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession = rtcSession, sharedKey} - toView $ CRCallAnswer user ct rtcSession + toView $ CEvtCallAnswer user ct rtcSession pure (Just call {callState = callState'}, Just . ACIContent SMDRcv $ CIRcvCall CISCallNegotiated 0) _ -> do msgCallStateError "x.call.answer" call @@ -2339,12 +2340,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} - toView $ CRCallExtraInfo user ct rtcExtraInfo + toView $ CEvtCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} - toView $ CRCallExtraInfo user ct rtcExtraInfo + toView $ CEvtCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) _ -> do msgCallStateError "x.call.extra" call @@ -2354,7 +2355,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xCallEnd :: Contact -> CallId -> RcvMessage -> CM () xCallEnd ct callId msg = msgCurrentCall ct callId "x.call.end" msg $ \Call {chatItemId} -> do - toView $ CRCallEnded user ct + toView $ CEvtCallEnded user ct (Nothing,) <$> callStatusItemContent user ct chatItemId WCSDisconnected msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> (Call -> CM (Maybe Call, Maybe ACIContent)) -> CM () @@ -2377,7 +2378,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = timed_ <- callTimed ct aciContent updateDirectChatItemView user ct chatItemId aciContent False False timed_ $ Just msgId forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect ctId', chatItemId) + startProximateTimedItemThread user (ChatRef CTDirect ctId' Nothing, chatItemId) msgCallStateError :: Text -> Call -> CM () msgCallStateError eventName Call {callState} = @@ -2396,7 +2397,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where merge c1' c2' = do c2'' <- withStore $ \db -> mergeContactRecords db vr user c1' c2' - toView $ CRContactsMerged user c1' c2' c2'' + toView $ CEvtContactsMerged user c1' c2' c2'' when (directOrUsed c2'') $ showSecurityCodeChanged c2'' pure $ Just c2'' where @@ -2435,14 +2436,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = associateMemberWithContact c1 m2@GroupMember {groupId} = do withStore' $ \db -> associateMemberWithContactRecord db user c1 m2 g <- withStore $ \db -> getGroupInfo db vr user groupId - toView $ CRContactAndMemberAssociated user c1 g m2 c1 + toView $ CEvtContactAndMemberAssociated user c1 g m2 c1 pure c1 associateContactWithMember :: GroupMember -> Contact -> CM Contact associateContactWithMember m1@GroupMember {groupId} c2 = do c2' <- withStore $ \db -> associateContactWithMemberRecord db vr user m1 c2 g <- withStore $ \db -> getGroupInfo db vr user groupId - toView $ CRContactAndMemberAssociated user c2 g m1 c2' + toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Bool) @@ -2451,36 +2452,63 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - let contactUsed = connDirect activeConn - ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed - toView $ CRContactConnecting user ct + ct <- withStore $ \db -> createDirectContact db user conn' p + toView $ CEvtContactConnecting user ct pure (conn', False) XGrpLinkInv glInv -> do (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv - toView $ CRGroupLinkConnecting user gInfo host + toView $ CEvtGroupLinkConnecting user gInfo host + pure (conn', True) + XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do + (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct + toView $ CEvtGroupLinkConnecting user gInfo host + toViewTE $ TEGroupLinkRejected user gInfo rejectionReason pure (conn', True) -- TODO show/log error, other events in SMP confirmation _ -> pure (conn', False) - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> CM () - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msg brokerTs = do + xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM () + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msgScope_ msg brokerTs = do checkHostRole m memRole unless (sameMemberId memId $ membership gInfo) $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo - toView $ CRUnknownMemberAnnounced user gInfo m unknownMember updatedMember - memberAnnouncedToView updatedMember + (updatedMember, gInfo') <- withStore $ \db -> do + updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus + gInfo' <- if memberPending updatedMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (updatedMember, gInfo') + toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo' Right _ -> messageError "x.grp.mem.new error: member already exists" Left _ -> do - newMember <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced - memberAnnouncedToView newMember + (newMember, gInfo') <- withStore $ \db -> do + newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus + gInfo' <- if memberPending newMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (newMember, gInfo') + memberAnnouncedToView newMember gInfo' where - memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do + initialStatus = case msgScope_ of + Just (MSMember _) -> GSMemPendingReview + _ -> GSMemAnnounced + memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} gInfo' = do + (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event) - groupMsgToView gInfo ci - toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent event) + groupMsgToView cInfo ci + case scopeInfo of + Just (GCSIMemberSupport _) -> do + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGENewMemberPendingReview) (Just brokerTs) + _ -> pure () + toView $ CEvtJoinedGroupMemberConnecting user gInfo' m announcedMember' + getMemNewChatScope announcedMember = case msgScope_ of + Nothing -> pure (announcedMember, Nothing) + Just (MSMember _) -> do + (announcedMember', scopeInfo) <- mkMemberSupportChatInfo announcedMember + pure (announcedMember', Just scopeInfo) xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> CM () xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do @@ -2490,17 +2518,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right _ -> messageError "x.grp.mem.intro ignored: member already exists" Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) - subMode <- chatReadVar subscriptionMode - -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second - groupConnIds <- createConn subMode - directConnIds <- case memChatVRange of - Nothing -> Just <$> createConn subMode + case memChatVRange of + Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" Just (ChatVersionRange mcvr) - | maxVersion mcvr >= groupDirectInvVersion -> pure Nothing - | otherwise -> Just <$> createConn subMode - let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange - void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode + | maxVersion mcvr >= groupDirectInvVersion -> do + subMode <- chatReadVar subscriptionMode + -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second + groupConnIds <- createConn subMode + let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds subMode + | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" where createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode @@ -2520,7 +2547,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - sendGroupMemberMessage user gInfo reMember (XGrpMemFwd (memberInfo m) introInv) (Just introId) $ + sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo m) introInv) (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -2537,7 +2564,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- For now, this branch compensates for the lack of delayed message delivery. Left _ -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced Right m' -> pure m' - withStore' $ \db -> saveMemberInvitation db toMember introInv + -- TODO [knocking] separate pending statuses from GroupMemberStatus? + -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? + -- TODO keep as is? (GSMemIntroInvited has no purpose) + let newMemberStatus = if memberPending toMember then memberStatus toMember else GSMemIntroInvited + withStore' $ \db -> saveMemberInvitation db toMember introInv newMemberStatus subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito let membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership @@ -2565,9 +2596,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo ci - toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci + toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -2575,7 +2607,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM () xGrpMemRestrict - gInfo@GroupInfo {groupId, membership = GroupMember {memberId = membershipMemId}} + gInfo@GroupInfo {membership = GroupMember {memberId = membershipMemId}} m@GroupMember {memberRole = senderRole} memId MemberRestrictions {restriction} @@ -2586,25 +2618,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.restrict: admin blocks you" | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} + Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} + | blockedByAdmin == mrsBlocked restriction -> pure () | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do - bm' <- setMemberBlocked bmId - toggleNtf user bm' (not blocked) + bm' <- setMemberBlocked bm + toggleNtf bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs ciContent - groupMsgToView gInfo ci - toView CRMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked} + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent + groupMsgToView cInfo ci + toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId - bm' <- setMemberBlocked $ groupMemberId' bm - toView $ CRUnknownMemberBlocked user gInfo m bm' + bm' <- setMemberBlocked bm + toView $ CEvtUnknownMemberBlocked user gInfo m bm' Left e -> throwError $ ChatErrorStore e where - setMemberBlocked bmId = - withStore $ \db -> do - liftIO $ updateGroupMemberBlocked db user groupId bmId restriction - getGroupMember db vr user groupId bmId + setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () @@ -2648,8 +2679,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> updateStatus introId GMIntroReConnected updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM () - xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg brokerTs = do + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> RcvMessage -> UTCTime -> CM () + xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages msg brokerTs = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId then checkRole membership $ do @@ -2658,36 +2689,48 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = members <- withStore' $ \db -> getGroupMembers db vr user gInfo deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved + when withMessages $ deleteMessages membership SMDSnd deleteMemberItem RGEUserDeleted - toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m + toView $ CEvtDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m withMessages else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.del with unknown member ID" Right member@GroupMember {groupMemberId, memberProfile} -> checkRole member $ do -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave - deleteMemberConnection user member + deleteMemberConnection member -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user member + gInfo' <- deleteOrUpdateMemberRecord user gInfo member + when withMessages $ deleteMessages member SMDRcv deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} + toView $ CEvtDeletedMember user gInfo' m member {memberStatus = GSMemRemoved} withMessages where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo ci + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci + deleteMessages :: MsgDirectionI d => GroupMember -> SMsgDirection d -> CM () + deleteMessages delMem msgDir + | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupMemberCIs user gInfo delMem m msgDir + | otherwise = markGroupMemberCIsDeleted user gInfo delMem m xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpLeave gInfo m msg brokerTs = do - deleteMemberConnection user m + deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item - withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) - groupMsgToView gInfo ci - toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} + gInfo' <- withStore' $ \db -> do + updateGroupMemberStatus db userId m GSMemLeft + if gmRequiresAttention m + then decreaseGroupMembersRequireAttention db user gInfo + else pure gInfo + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + groupMsgToView cInfo ci + toView $ CEvtLeftMember user gInfo'' m' {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do @@ -2698,9 +2741,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure members -- member records are not deleted to keep history deleteMembersConnections user ms - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) - groupMsgToView gInfo ci - toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + groupMsgToView cInfo ci + toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM () xGrpInfo g@GroupInfo {groupProfile = p, businessChat} m@GroupMember {memberRole} p' msg brokerTs @@ -2708,12 +2752,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = case businessChat of Nothing -> unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' - toView $ CRGroupUpdated user g g' (Just m) - let cd = CDGroupRcv g' m + (g'', m', scopeInfo) <- mkGroupChatScope g' m + toView $ CEvtGroupUpdated user g g'' (Just m') + let cd = CDGroupRcv g'' scopeInfo m' unless (sameGroupProfileInfo p p') $ do - ci <- saveRcvChatItem user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') - groupMsgToView g' ci - createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' + (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') + groupMsgToView cInfo ci + createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> CM () @@ -2725,9 +2770,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupPrefs_ g@GroupInfo {groupProfile = p} m ps' = unless (groupPreferences p == Just ps') $ do g' <- withStore' $ \db -> updateGroupPreferences db user g ps' - toView $ CRGroupUpdated user g g' (Just m) - let cd = CDGroupRcv g' m - createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' + toView $ CEvtGroupUpdated user g g' (Just m) + (g'', m', scopeInfo) <- mkGroupChatScope g' m + let cd = CDGroupRcv g'' scopeInfo m' + createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> CM () xGrpDirectInv g m mConn connReq mContent_ msg brokerTs = do @@ -2767,15 +2813,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do - createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing - toView $ CRNewMemberContactReceivedInv user mCt' g m' + (g', m'', scopeInfo) <- mkGroupChatScope g m' + createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing + toView $ CEvtNewMemberContactReceivedInv user mCt' g' m'' forM_ mContent_ $ \mc -> do - ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci] + (ci, cInfo) <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] securityCodeChanged :: Contact -> CM () securityCodeChanged ct = do - toView $ CRContactVerificationReset user ct + toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> CM () @@ -2785,7 +2832,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right author -> processForwardedMsg author msg Left (SEGroupMemberNotFoundByMemberId _) -> do unknownAuthor <- createUnknownMember gInfo memberId - toView $ CRUnknownMemberCreated user gInfo m unknownAuthor + toView $ CEvtUnknownMemberCreated user gInfo m unknownAuthor processForwardedMsg unknownAuthor msg Left e -> throwError $ ChatErrorStore e where @@ -2795,16 +2842,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let body = LB.toStrict $ J.encode msg rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg case event of - XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs True - XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent rcvMsg msgTs ttl live + XMsgNew mc -> memberCanSend author scope $ newGroupContentMessage gInfo author mc rcvMsg msgTs True + where ExtMsgContent {scope} = mcExtMsgContent mc + -- file description is always allowed, to allow sending files to support scope + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo author sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend author msgScope $ groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs XMsgReact sharedMsgId (Just memId) reaction add -> groupMsgReaction gInfo author sharedMsgId memId reaction add rcvMsg msgTs XFileCancel sharedMsgId -> xFileCancelGroup gInfo author sharedMsgId XInfo p -> xInfoMember gInfo author p msgTs - XGrpMemNew memInfo -> xGrpMemNew gInfo author memInfo rcvMsg msgTs + XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs XGrpMemRole memId memRole -> xGrpMemRole gInfo author memId memRole rcvMsg msgTs - XGrpMemDel memId -> xGrpMemDel gInfo author memId rcvMsg msgTs + XGrpMemDel memId withMessages -> xGrpMemDel gInfo author memId withMessages rcvMsg msgTs XGrpLeave -> xGrpLeave gInfo author rcvMsg msgTs XGrpDel -> xGrpDel gInfo author rcvMsg msgTs XGrpInfo p' -> xGrpInfo gInfo author p' rcvMsg msgTs @@ -2813,7 +2862,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createUnknownMember :: GroupInfo -> MemberId -> CM GroupMember createUnknownMember gInfo memberId = do - let name = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId $ memberId + let name = nameFromMemberId memberId withStore $ \db -> createNewUnknownGroupMember db vr user gInfo memberId name directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () @@ -2825,17 +2874,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus - updateGroupItemsStatus gInfo m conn agentMsgId (GSSRcvd msgRcptStatus) Nothing + updateGroupItemsStatus gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing -- Searches chat items for many agent message IDs and updates their status updateDirectItemsStatusMsgs :: Contact -> Connection -> [AgentMsgId] -> CIStatus 'MDSnd -> CM () updateDirectItemsStatusMsgs ct conn msgIds newStatus = do cis <- withStore' $ \db -> forM msgIds $ \msgId -> runExceptT $ updateDirectItemsStatus' db ct conn msgId newStatus let acis = map ctItem $ concat $ rights cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) @@ -2843,7 +2893,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateDirectItemStatus ct conn msgId newStatus = do cis <- withStore $ \db -> updateDirectItemsStatus' db ct conn msgId newStatus let acis = map ctItem cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) @@ -2873,10 +2923,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupItemsStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus viaProxy_ = do items <- withStore' (\db -> getGroupChatItemsByAgentMsgId db user groupId connId msgId) cis <- catMaybes <$> withStore (\db -> mapM (updateItem db) items) - let acis = map gItem cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + -- SENT and RCVD events are received for messages that may be batched in single scope, + -- so we can look up scope of first item + scopeInfo <- case cis of + (ci : _) -> withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (chatItemId' ci) + _ -> pure Nothing + let acis = map (gItem scopeInfo) cis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where - gItem = AChatItem SCTGroup SMDSnd (GroupChat gInfo) + gItem scopeInfo ci = AChatItem SCTGroup SMDSnd (GroupChat gInfo scopeInfo) ci updateItem :: DB.Connection -> CChatItem 'CTGroup -> ExceptT StoreError IO (Maybe (ChatItem 'CTGroup 'MDSnd)) updateItem db = \case (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ SSPComplete}}) -> pure Nothing diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 8e82bfe727..9811556fe0 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} @@ -16,24 +17,23 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A -import Data.Char (isDigit, isPunctuation) +import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace) import Data.Either (fromRight) import Data.Functor (($>)) import Data.List (foldl', intercalate) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe, isNothing, mapMaybe) import Data.Semigroup (sconcat) import Data.String 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,19 +49,29 @@ 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 deriving (Eq, Show) -data SimplexLinkType = XLContact | XLInvitation | XLGroup +mentionedNames :: MarkdownList -> [Text] +mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) + where + mentionedName = \case + Mention name -> Just name + _ -> Nothing + +data SimplexLinkType = XLContact | XLInvitation | XLGroup | XLChannel deriving (Eq, Show) colored :: Color -> Format colored = Colored . FormatColor +{-# INLINE colored #-} markdown :: Format -> Text -> Markdown markdown = Markdown . Just +{-# INLINE markdown #-} instance Semigroup Markdown where m <> (Markdown _ "") = m @@ -163,6 +173,7 @@ markdownP = mconcat <$> A.many' fragmentP '`' -> formattedP '`' Snippet '#' -> A.char '#' *> secretP '!' -> coloredP <|> wordP + '@' -> mentionP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP @@ -192,14 +203,20 @@ markdownP = mconcat <$> A.many' fragmentP if T.null s || T.last s == ' ' then fail "not colored" else pure $ markdown (colored clr) s + mentionP = do + c <- A.char '@' *> A.peekChar' + (name, punct) <- displayNameTextP_ + let sName = if c == '\'' then '\'' `T.cons` name `T.snoc` '\'' else name + mention = markdown (Mention name) ('@' `T.cons` sName) + pure $ if T.null punct then mention else mention :|: unmarked punct colorP = A.anyChar >>= \case - 'r' -> "ed" $> Red <|> pure Red - 'g' -> "reen" $> Green <|> pure Green - 'b' -> "lue" $> Blue <|> pure Blue - 'y' -> "ellow" $> Yellow <|> pure Yellow - 'c' -> "yan" $> Cyan <|> pure Cyan - 'm' -> "agenta" $> Magenta <|> pure Magenta + 'r' -> optional "ed" $> Red + 'g' -> optional "reen" $> Green + 'b' -> optional "lue" $> Blue + 'y' -> optional "ellow" $> Yellow + 'c' -> optional "yan" $> Cyan + 'm' -> optional "agenta" $> Magenta '1' -> pure Red '2' -> pure Green '3' -> pure Blue @@ -221,35 +238,105 @@ markdownP = mconcat <$> A.many' fragmentP wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s - | isUri s = - let t = T.takeWhileEnd isPunctuation' s - uri = uriMarkdown $ T.dropWhileEnd isPunctuation' s - in if T.null t then uri else uri :|: unmarked t - | isEmail s = markdown Email s + | isUri s' = res $ uriMarkdown s' + | isDomain s' = res $ markdown Uri s' + | isEmail s' = res $ markdown Email s' | otherwise = unmarked s + where + punct = T.takeWhileEnd isPunctuation' s + s' = T.dropWhileEnd isPunctuation' s + res md' = if T.null punct then md' else md' :|: unmarked punct isPunctuation' = \case '/' -> False ')' -> 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:/"] + -- matches what is likely to be a domain, not all valid domain names + isDomain s = case T.splitOn "." s of + [name, tld] -> isDomain_ name tld + [sub, name, tld] -> T.length sub >= 3 && T.length sub <= 8 && isDomain_ name tld + _ -> False + where + isDomain_ name tld = + (let n = T.length name in n >= 1 && n <= 24) + && (let n = T.length tld in n >= 2 && n <= 8) + && (let p c = isAscii c && isAlpha c in T.all p name && T.all p tld) 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 + Nothing -> t + Just f -> case f of + Bold -> around '*' + Italic -> around '_' + StrikeThrough -> around '~' + Snippet -> around '`' + Secret -> around '#' + Colored (FormatColor c) -> color c + Uri -> t + SimplexLink {} -> t + Mention _ -> t + Email -> t + Phone -> t + where + around c = c `T.cons` t `T.snoc` c + color c = case colorStr c of + Just cStr -> cStr <> t `T.snoc` '!' + Nothing -> t + colorStr = \case + Red -> Just "!1 " + Green -> Just "!2 " + Blue -> Just "!3 " + Yellow -> Just "!4 " + Cyan -> Just "!5 " + Magenta -> Just "!6 " + Black -> Nothing + White -> Nothing + +displayNameTextP :: Parser Text +displayNameTextP = displayNameTextP_ >>= \(t, sfx) -> if T.null sfx then pure t else fail "Name ends with punctuation" +{-# INLINE displayNameTextP #-} + +displayNameTextP_ :: Parser (Text, Text) +displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill isSpace + where + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s) + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' + +-- quotes names that contain spaces or end on punctuation +viewName :: Text -> Text +viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s) then "'" <> s <> "'" else s $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 55542b1d2f..dfd37527d1 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -31,6 +31,7 @@ import Data.Char (isSpace) import Data.Int (Int64) import Data.Kind (Constraint) import Data.List.NonEmpty (NonEmpty) +import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -46,21 +47,52 @@ import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON) import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord) +data GroupChatScope + = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support + deriving (Eq, Show, Ord) + +data GroupChatScopeTag + = GCSTMemberSupport_ + deriving (Eq, Show) + +instance FromField GroupChatScopeTag where fromField = fromTextField_ textDecode + +instance ToField GroupChatScopeTag where toField = toField . textEncode + +instance TextEncoding GroupChatScopeTag where + textDecode = \case + "member_support" -> Just GCSTMemberSupport_ + _ -> Nothing + textEncode = \case + GCSTMemberSupport_ -> "member_support" + data ChatName = ChatName {chatType :: ChatType, chatName :: Text} deriving (Show) +data SendName + = SNDirect ContactName + | SNGroup GroupName (Maybe GroupScopeName) + | SNLocal + deriving (Show) + +data GroupScopeName + = GSNMemberSupport (Maybe ContactName) + deriving (Show) + chatTypeStr :: ChatType -> Text chatTypeStr = \case CTDirect -> "@" @@ -72,44 +104,52 @@ chatTypeStr = \case chatNameStr :: ChatName -> String chatNameStr (ChatName cType name) = T.unpack $ chatTypeStr cType <> if T.any isSpace name then "'" <> name <> "'" else name -data ChatRef = ChatRef ChatType Int64 +data ChatRef = ChatRef ChatType Int64 (Maybe GroupChatScope) deriving (Eq, Show, Ord) data ChatInfo (c :: ChatType) where DirectChat :: Contact -> ChatInfo 'CTDirect - GroupChat :: GroupInfo -> ChatInfo 'CTGroup + GroupChat :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatInfo 'CTGroup LocalChat :: NoteFolder -> ChatInfo 'CTLocal ContactRequest :: UserContactRequest -> ChatInfo 'CTContactRequest ContactConnection :: PendingContactConnection -> ChatInfo 'CTContactConnection deriving instance Show (ChatInfo c) -chatInfoChatTs :: ChatInfo c -> Maybe UTCTime -chatInfoChatTs = \case - DirectChat Contact {chatTs} -> chatTs - GroupChat GroupInfo {chatTs} -> chatTs - _ -> Nothing +data GroupChatScopeInfo + = GCSIMemberSupport {groupMember_ :: Maybe GroupMember} + deriving (Show) + +toChatScope :: GroupChatScopeInfo -> GroupChatScope +toChatScope = \case + GCSIMemberSupport {groupMember_} -> GCSMemberSupport $ groupMemberId' <$> groupMember_ + +toMsgScope :: GroupInfo -> GroupChatScopeInfo -> MsgScope +toMsgScope GroupInfo {membership} = \case + GCSIMemberSupport {groupMember_} -> MSMember $ memberId' $ fromMaybe membership groupMember_ chatInfoToRef :: ChatInfo c -> ChatRef chatInfoToRef = \case - DirectChat Contact {contactId} -> ChatRef CTDirect contactId - GroupChat GroupInfo {groupId} -> ChatRef CTGroup groupId - LocalChat NoteFolder {noteFolderId} -> ChatRef CTLocal noteFolderId - ContactRequest UserContactRequest {contactRequestId} -> ChatRef CTContactRequest contactRequestId - ContactConnection PendingContactConnection {pccConnId} -> ChatRef CTContactConnection pccConnId + DirectChat Contact {contactId} -> ChatRef CTDirect contactId Nothing + GroupChat GroupInfo {groupId} scopeInfo -> ChatRef CTGroup groupId (toChatScope <$> scopeInfo) + LocalChat NoteFolder {noteFolderId} -> ChatRef CTLocal noteFolderId Nothing + ContactRequest UserContactRequest {contactRequestId} -> ChatRef CTContactRequest contactRequestId Nothing + ContactConnection PendingContactConnection {pccConnId} -> ChatRef CTContactConnection pccConnId Nothing chatInfoMembership :: ChatInfo c -> Maybe GroupMember chatInfoMembership = \case - GroupChat GroupInfo {membership} -> Just membership + GroupChat GroupInfo {membership} _scopeInfo -> Just membership _ -> Nothing data JSONChatInfo = JCInfoDirect {contact :: Contact} - | JCInfoGroup {groupInfo :: GroupInfo} + | JCInfoGroup {groupInfo :: GroupInfo, groupChatScope :: Maybe GroupChatScopeInfo} | JCInfoLocal {noteFolder :: NoteFolder} | JCInfoContactRequest {contactRequest :: UserContactRequest} | JCInfoContactConnection {contactConnection :: PendingContactConnection} +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCSI") ''GroupChatScopeInfo) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) instance ChatTypeI c => FromJSON (ChatInfo c) where @@ -122,7 +162,7 @@ instance ToJSON (ChatInfo c) where jsonChatInfo :: ChatInfo c -> JSONChatInfo jsonChatInfo = \case DirectChat c -> JCInfoDirect c - GroupChat g -> JCInfoGroup g + GroupChat g s -> JCInfoGroup g s LocalChat l -> JCInfoLocal l ContactRequest g -> JCInfoContactRequest g ContactConnection c -> JCInfoContactConnection c @@ -134,7 +174,7 @@ deriving instance Show AChatInfo jsonAChatInfo :: JSONChatInfo -> AChatInfo jsonAChatInfo = \case JCInfoDirect c -> AChatInfo SCTDirect $ DirectChat c - JCInfoGroup g -> AChatInfo SCTGroup $ GroupChat g + JCInfoGroup g s -> AChatInfo SCTGroup $ GroupChat g s JCInfoLocal l -> AChatInfo SCTLocal $ LocalChat l JCInfoContactRequest g -> AChatInfo SCTContactRequest $ ContactRequest g JCInfoContactConnection c -> AChatInfo SCTContactConnection $ ContactConnection c @@ -150,6 +190,9 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, meta :: CIMeta c d, content :: CIContent d, + -- The `mentions` map prevents loading all members from UI. + -- The key is a name used in the message text, used to look up CIMention. + mentions :: Map MemberName CIMention, formattedText :: Maybe MarkdownList, quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], @@ -157,18 +200,26 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show) -isMention :: ChatItem c d -> Bool -isMention ChatItem {chatDir, quotedItem} = case chatDir of - CIDirectRcv -> userItem quotedItem - CIGroupRcv _ -> userItem quotedItem - _ -> False - where - userItem = \case - Nothing -> False - Just CIQuote {chatDir = cd} -> case cd of - CIQDirectSnd -> True - CIQGroupSnd -> True - _ -> False +data CIMention = CIMention + { memberId :: MemberId, + -- member record can be created later than the mention is received + memberRef :: Maybe CIMentionMember + } + deriving (Eq, Show) + +data CIMentionMember = CIMentionMember + { groupMemberId :: GroupMemberId, + displayName :: Text, -- use `displayName` in copy/share actions + localAlias :: Maybe Text, -- use `fromMaybe displayName localAlias` in chat view + memberRole :: GroupMemberRole -- shown for admins/owners in the message + } + deriving (Eq, Show) + +isACIUserMention :: AChatItem -> Bool +isACIUserMention (AChatItem _ _ _ ci) = isUserMention ci + +isUserMention :: ChatItem c d -> Bool +isUserMention ChatItem {meta = CIMeta {userMention}} = userMention data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd @@ -247,6 +298,16 @@ chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of CIGroupSnd -> membership CIGroupRcv m -> m +chatItemRcvFromMember :: ChatItem c d -> Maybe GroupMember +chatItemRcvFromMember ChatItem {chatDir} = case chatDir of + CIGroupRcv m -> Just m + _ -> Nothing + +chatItemIsRcvNew :: ChatItem c d -> Bool +chatItemIsRcvNew ChatItem {meta = CIMeta {itemStatus}} = case itemStatus of + CISRcvNew -> True + _ -> False + ciReactionAllowed :: ChatItem c d -> Bool ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content @@ -254,8 +315,8 @@ ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv - CDGroupSnd :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd - CDGroupRcv :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv + CDGroupSnd :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatDirection 'CTGroup 'MDSnd + CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv CDLocalSnd :: NoteFolder -> ChatDirection 'CTLocal 'MDSnd CDLocalRcv :: NoteFolder -> ChatDirection 'CTLocal 'MDRcv @@ -263,8 +324,8 @@ toCIDirection :: ChatDirection c d -> CIDirection c d toCIDirection = \case CDDirectSnd _ -> CIDirectSnd CDDirectRcv _ -> CIDirectRcv - CDGroupSnd _ -> CIGroupSnd - CDGroupRcv _ m -> CIGroupRcv m + CDGroupSnd _ _ -> CIGroupSnd + CDGroupRcv _ _ m -> CIGroupRcv m CDLocalSnd _ -> CILocalSnd CDLocalRcv _ -> CILocalRcv @@ -272,8 +333,8 @@ toChatInfo :: ChatDirection c d -> ChatInfo c toChatInfo = \case CDDirectSnd c -> DirectChat c CDDirectRcv c -> DirectChat c - CDGroupSnd g -> GroupChat g - CDGroupRcv g _ -> GroupChat g + CDGroupSnd g s -> GroupChat g s + CDGroupRcv g s _ -> GroupChat g s CDLocalSnd l -> LocalChat l CDLocalRcv l -> LocalChat l @@ -310,6 +371,7 @@ deriving instance Show AChat data ChatStats = ChatStats { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API + unreadMentions :: Int, -- returned both in /_get chat initial API and in /_get chats API reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API minUnreadItemId :: ChatItemId, unreadChat :: Bool @@ -317,7 +379,7 @@ data ChatStats = ChatStats deriving (Show) emptyChatStats :: ChatStats -emptyChatStats = ChatStats 0 0 0 False +emptyChatStats = ChatStats 0 0 0 0 False data NavigationInfo = NavigationInfo { afterUnread :: Int, @@ -346,6 +408,12 @@ aChatItemTs (AChatItem _ _ _ ci) = chatItemTs' ci aChatItemDir :: AChatItem -> MsgDirection aChatItemDir (AChatItem _ sMsgDir _ _) = toMsgDirection sMsgDir +aChatItemRcvFromMember :: AChatItem -> Maybe GroupMember +aChatItemRcvFromMember (AChatItem _ _ _ ci) = chatItemRcvFromMember ci + +aChatItemIsRcvNew :: AChatItem -> Bool +aChatItemIsRcvNew (AChatItem _ _ _ ci) = chatItemIsRcvNew ci + updateFileStatus :: forall c d. ChatItem c d -> CIFileStatus d -> ChatItem c d updateFileStatus ci@ChatItem {file} status = case file of Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} @@ -364,6 +432,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemEdited :: Bool, itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, + userMention :: Bool, -- True for messages that mention user or reply to user messages deletable :: Bool, editable :: Bool, forwardedByMember :: Maybe GroupMemberId, @@ -372,11 +441,11 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta } deriving (Show) -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention currentTs itemTs forwardedByMember createdAt updatedAt = let deletable = deletable' itemContent itemDeleted itemTs nominalDay currentTs editable = deletable && isNothing itemForwarded - in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable, forwardedByMember, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, deletable, editable, forwardedByMember, createdAt, updatedAt} deletable' :: forall c d. ChatTypeI c => CIContent d -> Maybe (CIDeleted c) -> UTCTime -> NominalDiffTime -> UTCTime -> Bool deletable' itemContent itemDeleted itemTs allowedInterval currentTs = @@ -401,6 +470,7 @@ dummyMeta itemId ts itemText = itemEdited = False, itemTimed = Nothing, itemLive = Nothing, + userMention = False, deletable = False, editable = False, forwardedByMember = Nothing, @@ -915,7 +985,10 @@ ciStatusNew = case msgDirection @d of ciCreateStatus :: forall d. MsgDirectionI d => CIContent d -> CIStatus d ciCreateStatus content = case msgDirection @d of SMDSnd -> ciStatusNew - SMDRcv -> if ciRequiresAttention content then ciStatusNew else CISRcvRead + SMDRcv + | isCIReport content -> CISRcvRead + | ciRequiresAttention content -> ciStatusNew + | otherwise -> CISRcvRead membersGroupItemStatus :: [(GroupSndStatus, Int)] -> CIStatus 'MDSnd membersGroupItemStatus memStatusCounts @@ -1247,14 +1320,14 @@ data ChatItemVersion = ChatItemVersion deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion -mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content +mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent content where CIMeta {itemId, itemTs, createdAt} = meta version mc = ChatItemVersion { chatItemVersionId = itemId, msgContent = mc, - formattedText = parseMaybeMarkdownList $ msgContentText mc, + formattedText, itemVersionTs = itemTs, createdAt = createdAt } @@ -1358,6 +1431,8 @@ instance MsgDirectionI d => ToJSON (CIFile d) where toJSON = $(JQ.mkToJSON defaultJSON ''CIFile) toEncoding = $(JQ.mkToEncoding defaultJSON ''CIFile) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCS") ''GroupChatScope) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIDirection) instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where @@ -1387,6 +1462,10 @@ $(JQ.deriveToJSON defaultJSON ''CIQuote) $(JQ.deriveJSON defaultJSON ''CIReactionCount) +$(JQ.deriveJSON defaultJSON ''CIMentionMember) + +$(JQ.deriveJSON defaultJSON ''CIMention) + instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 60d5464b79..cc6529831c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -182,6 +182,9 @@ ciMsgContent = \case CIRcvMsgContent mc -> Just mc _ -> Nothing +isCIReport :: CIContent d -> Bool +isCIReport = maybe False isReport . ciMsgContent + data MsgDecryptError = MDERatchetHeader | MDETooManySkipped @@ -206,6 +209,8 @@ ciRequiresAttention content = case msgDirection @d of CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False + RGEMemberAccepted {} -> False + RGEUserAccepted -> False RGEMemberLeft -> False RGEMemberRole {} -> False RGEMemberBlocked {} -> False @@ -217,6 +222,7 @@ ciRequiresAttention content = case msgDirection @d of RGEInvitedViaGroupLink -> False RGEMemberCreatedContact -> False RGEMemberProfileUpdated {} -> False + RGENewMemberPendingReview -> True CIRcvConnEvent _ -> True CIRcvChatFeature {} -> False CIRcvChatPreference {} -> False @@ -317,6 +323,8 @@ rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case RGEMemberAdded _ p -> "added " <> profileToText p RGEMemberConnected -> "connected" + RGEMemberAccepted _ p -> "accepted " <> profileToText p + RGEUserAccepted -> "accepted you" RGEMemberLeft -> "left" RGEMemberRole _ p r -> "changed role of " <> profileToText p <> " to " <> safeDecodeUtf8 (strEncode r) RGEMemberBlocked _ p blocked -> (if blocked then "blocked" else "unblocked") <> " " <> profileToText p @@ -328,6 +336,7 @@ rcvGroupEventToText = \case RGEInvitedViaGroupLink -> "invited via your group link" RGEMemberCreatedContact -> "started direct connection with you" RGEMemberProfileUpdated {} -> "updated profile" + RGENewMemberPendingReview -> "new member wants to join the group" sndGroupEventToText :: SndGroupEvent -> Text sndGroupEventToText = \case @@ -337,6 +346,18 @@ sndGroupEventToText = \case SGEMemberDeleted _ p -> "removed " <> profileToText p SGEUserLeft -> "left" SGEGroupUpdated _ -> "group profile updated" + SGEMemberAccepted _ _p -> "you accepted this member" + SGEUserPendingReview -> "please wait for group moderators to review your request to join the group" + +-- used to send to members with old version +pendingReviewMessage :: Text +pendingReviewMessage = + "Please wait for group moderators to review your request to join the group." + +-- used to send to members with old version +acceptedToGroupMessage :: Text +acceptedToGroupMessage = + "You are accepted to the group." rcvConnEventToText :: RcvConnEvent -> Text rcvConnEventToText = \case diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 74f7d94399..539c1f524c 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -14,7 +14,9 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) data RcvGroupEvent = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting - | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember + | RGEMemberConnected -- CEvtUserJoinedGroup, CRJoinedGroupMember, CEvtConnectedToGroupMember + | RGEMemberAccepted {groupMemberId :: GroupMemberId, profile :: Profile} + | RGEUserAccepted | RGEMemberLeft -- CRLeftMember | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} | RGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMemberBlockedForAll @@ -29,15 +31,18 @@ data RcvGroupEvent | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv | RGEMemberProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRGroupMemberUpdated + | RGENewMemberPendingReview deriving (Show) data SndGroupEvent = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMemberBlockedForAllUser + | SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMembersBlockedForAllUser | SGEUserRole {role :: GroupMemberRole} - | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember + | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMembers | SGEUserLeft -- CRLeftMemberUser | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + | SGEMemberAccepted {groupMemberId :: GroupMemberId, profile :: Profile} + | SGEUserPendingReview deriving (Show) data RcvConnEvent diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b30c004b97..194fc1bb06 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -13,10 +13,10 @@ import Control.Concurrent.STM import Control.Exception (SomeException, catch) import Control.Monad.Except import Control.Monad.Reader +import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) -import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -52,13 +52,15 @@ import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), Migrati import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..)) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..)) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) #if !defined(dbPostgres) +import Data.ByteArray (ScrubbedBytes) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB #endif data DBMigrationResult @@ -71,9 +73,19 @@ data DBMigrationResult $(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "DBM") ''DBMigrationResult) -data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} +data APIResult r + = APIResult {remoteHostId :: Maybe RemoteHostId, result :: r} + | APIError {remoteHostId :: Maybe RemoteHostId, error :: ChatError} -$(JQ.deriveToJSON defaultJSON ''APIResponse) +eitherToResult :: Maybe RemoteHostId -> Either ChatError r -> APIResult r +eitherToResult rhId = either (APIError rhId) (APIResult rhId) +{-# INLINE eitherToResult #-} + +$(pure []) + +instance ToJSON r => ToJSON (APIResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''APIResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''APIResult) foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString @@ -203,10 +215,10 @@ mobileChatOpts dbOptions = logAgent = Nothing, logFile = Nothing, tbqSize = 4096, + deviceName = Nothing, highlyAvailable = False, yesToUpMigrations = False }, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, @@ -237,7 +249,7 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers -- only used in tests chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = do - let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = True} + let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True} chatMigrateInitKey chatDBOpts False confirm False #endif @@ -285,21 +297,15 @@ chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString chatSendCmd cc = chatSendRemoteCmd cc Nothing chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO JSONByteString -chatSendRemoteCmd cc rh s = J.encode . APIResponse Nothing rh <$> runReaderT (execChatCommand rh s) cc +chatSendRemoteCmd cc rh s = J.encode . eitherToResult rh <$> runReaderT (execChatCommand rh s) cc chatRecvMsg :: ChatController -> IO JSONByteString -chatRecvMsg ChatController {outputQ} = json <$> readChatResponse +chatRecvMsg ChatController {outputQ} = J.encode . uncurry eitherToResult <$> readChatResponse where - json (corr, remoteHostId, resp) = J.encode APIResponse {corr, remoteHostId, resp} - readChatResponse = do - out@(_, _, cr) <- atomically $ readTBQueue outputQ - if filterEvent cr then pure out else readChatResponse - filterEvent = \case - CRGroupSubscribed {} -> False - CRGroupEmpty {} -> False - CRMemberSubSummary {} -> False - CRPendingSubSummary {} -> False - _ -> True + readChatResponse = + atomically (readTBQueue outputQ) >>= \case + (_, Right CEvtTerminalEvent {}) -> readChatResponse + out -> pure out chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 6af0c4a17e..c9b5e020d9 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -37,7 +37,6 @@ import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isNothing, mapMaybe) -import Data.Scientific (floatingOrInteger) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) @@ -46,21 +45,22 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions -import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (User) import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) usageConditionsCommit :: Text -usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" +usageConditionsCommit = "7471fd2af5838dc0467aebc570b5ea75e5df3209" previousConditionsCommit :: Text -previousConditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c" +previousConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" usageConditionsText :: Text usageConditionsText = @@ -68,32 +68,6 @@ usageConditionsText = in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|] ) -data DBStored = DBStored | DBNew - -data SDBStored (s :: DBStored) where - SDBStored :: SDBStored 'DBStored - SDBNew :: SDBStored 'DBNew - -deriving instance Show (SDBStored s) - -class DBStoredI s where sdbStored :: SDBStored s - -instance DBStoredI 'DBStored where sdbStored = SDBStored - -instance DBStoredI 'DBNew where sdbStored = SDBNew - -data DBEntityId' (s :: DBStored) where - DBEntityId :: Int64 -> DBEntityId' 'DBStored - DBNewEntity :: DBEntityId' 'DBNew - -deriving instance Show (DBEntityId' s) - -deriving instance Eq (DBEntityId' s) - -type DBEntityId = DBEntityId' 'DBStored - -type DBNewEntity = DBEntityId' 'DBNew - data OperatorTag = OTSimplex | OTFlux deriving (Eq, Ord, Show) @@ -117,19 +91,6 @@ instance TextEncoding OperatorTag where OTSimplex -> "simplex" OTFlux -> "flux" --- this and other types only define instances of serialization for known DB IDs only, --- entities without IDs cannot be serialized to JSON -instance FromField DBEntityId -#if defined(dbPostgres) - where - fromField f dat = DBEntityId <$> fromField f dat -#else - where - fromField f = DBEntityId <$> fromField f -#endif - -instance ToField DBEntityId where toField (DBEntityId i) = toField i - data UsageConditions = UsageConditions { conditionsId :: Int64, conditionsCommit :: Text, @@ -274,6 +235,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], @@ -296,6 +261,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 @@ -478,25 +446,6 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] userServers p = map aUserServer' . concatMap (servers' p) -instance ToJSON (DBEntityId' s) where - toEncoding = \case - DBEntityId i -> toEncoding i - DBNewEntity -> JE.null_ - toJSON = \case - DBEntityId i -> toJSON i - DBNewEntity -> J.Null - -instance DBStoredI s => FromJSON (DBEntityId' s) where - parseJSON v = case (v, sdbStored @s) of - (J.Null, SDBNew) -> pure DBNewEntity - (J.Number n, SDBStored) -> case floatingOrInteger n of - Left (_ :: Double) -> fail "bad DBEntityId" - Right i -> pure $ DBEntityId (fromInteger i) - _ -> fail "bad DBEntityId" - omittedField = case sdbStored @s of - SDBStored -> Nothing - SDBNew -> Just DBNewEntity - $(JQ.deriveJSON defaultJSON ''UsageConditions) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs new file mode 100644 index 0000000000..18a0b7ebed --- /dev/null +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -0,0 +1,118 @@ +{-# 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.Agent.Store.Entity +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_ + -- added for testing, not preset in the clients + <> ["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.hs b/src/Simplex/Chat/Options.hs index c58c792819..ff2af56b85 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -29,7 +29,7 @@ import Numeric.Natural (Natural) import Options.Applicative import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), updateStr, versionNumber, versionString) import Simplex.FileTransfer.Description (mb) -import Simplex.Messaging.Client (HostMode (..), SocksMode (..), textToHostMode) +import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth) @@ -38,7 +38,6 @@ import Simplex.Chat.Options.DB data ChatOpts = ChatOpts { coreOptions :: CoreChatOpts, - deviceName :: Maybe Text, chatCmd :: String, chatCmdDelay :: Int, chatCmdLog :: ChatCmdLog, @@ -64,6 +63,7 @@ data CoreChatOpts = CoreChatOpts logAgent :: Maybe LogLevel, logFile :: Maybe FilePath, tbqSize :: Natural, + deviceName :: Maybe Text, highlyAvailable :: Bool, yesToUpMigrations :: Bool } @@ -153,11 +153,17 @@ coreChatOptsP appDir defaultDbName = do <> metavar "SMP_PROXY_FALLBACK_MODE" <> help "Allow downgrade and connect directly: no, [when IP address is] protected (default), yes" ) - smpWebPort <- - switch + smpWebPortServers <- + flag' SWPAll ( long "smp-web-port" <> help "Use port 443 with SMP servers when not specified" ) + <|> option + strParse + ( long "smp-web-port-servers" + <> help "Use port 443 with SMP servers when not specified: all, preset (default), off" + <> value SWPPreset + ) t <- option auto @@ -212,6 +218,13 @@ coreChatOptsP appDir defaultDbName = do <> value 1024 <> showDefault ) + deviceName <- + optional $ + strOption + ( long "device-name" + <> metavar "DEVICE" + <> help "Device name to use in connections with remote hosts and controller" + ) highlyAvailable <- switch ( long "ha" @@ -236,7 +249,7 @@ coreChatOptsP appDir defaultDbName = do requiredHostMode, smpProxyMode_, smpProxyFallback_, - smpWebPort, + smpWebPortServers, tcpTimeout_ = Just $ useTcpTimeout socksProxy t, logTLSErrors }, @@ -246,6 +259,7 @@ coreChatOptsP appDir defaultDbName = do logAgent = if logAgent || logLevel == CLLDebug then Just $ agentLogLevel logLevel else Nothing, logFile, tbqSize, + deviceName, highlyAvailable, yesToUpMigrations } @@ -260,13 +274,6 @@ defaultHostMode = \case chatOptsP :: FilePath -> FilePath -> Parser ChatOpts chatOptsP appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName - deviceName <- - optional $ - strOption - ( long "device-name" - <> metavar "DEVICE" - <> help "Device name to use in connections with remote hosts and controller" - ) chatCmd <- strOption ( long "execute" @@ -356,7 +363,6 @@ chatOptsP appDir defaultDbName = do pure ChatOpts { coreOptions, - deviceName, chatCmd, chatCmdDelay, chatCmdLog, 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/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 11eaf7e58c..0507076613 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Options.SQLite where @@ -11,11 +12,13 @@ import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import Simplex.Messaging.Agent.Store.SQLite.DB (TrackQueries (..)) import System.FilePath (combine) data ChatDbOpts = ChatDbOpts { dbFilePrefix :: String, dbKey :: ScrubbedBytes, + trackQueries :: TrackQueries, vacuumOnMigration :: Bool } @@ -43,17 +46,24 @@ chatDbOptsP appDir defaultDbName = do ( long "disable-vacuum" <> help "Do not vacuum database after migrations" ) - pure ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = not disableVacuum} + pure + ChatDbOpts + { dbFilePrefix, + dbKey, + trackQueries = TQSlow 5000, -- 5ms + vacuumOnMigration = not disableVacuum + } dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} dbSuffix keepKey = do +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey = do DBOpts { dbFilePath = dbFilePrefix <> dbSuffix, dbKey, keepKey, + track = trackQueries, vacuum = vacuumOnMigration } @@ -71,18 +81,14 @@ mobileDbOpts fp key = do ChatDbOpts { dbFilePrefix, dbKey, + trackQueries = TQSlow 5000, -- 5ms vacuumOnMigration = True } -- used to create new chat controller, -- at that point database is already opened, and the key in options is not used removeDbKey :: ChatDbOpts -> ChatDbOpts -removeDbKey ChatDbOpts {dbFilePrefix, vacuumOnMigration} = - ChatDbOpts - { dbFilePrefix, - dbKey = "", - vacuumOnMigration - } +removeDbKey opts = opts {dbKey = ""} :: ChatDbOpts errorDbStr :: DBOpts -> String errorDbStr DBOpts {dbFilePath} = dbFilePath diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index 95f5f16207..5b6c9222cc 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -1492,7 +1492,6 @@ adjectives = "Recommendable", "Rectangular", "Recuperative", - "Red", "Refined", "Reflecting", "Reflective", @@ -2929,7 +2928,6 @@ nouns = "Sister", "Size", "Skill", - "Skin", "Skipper", "Sleek", "Slick", diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 9cbc63b0e2..151b1b0d27 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -33,8 +33,11 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Either (fromRight) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, mapMaybe) import Data.String import Data.Text (Text) @@ -50,11 +53,12 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) @@ -72,12 +76,14 @@ import Simplex.Messaging.Version hiding (version) -- 10 - business chats (2024-11-29) -- 11 - fix profile update in business chats (2024-12-05) -- 12 - support sending and receiving content reports (2025-01-03) +-- 14 - support sending and receiving group join rejection (2025-02-24) +-- 15 - support specifying message scopes for group messages (2025-03-12) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 12 +currentChatVersion = VersionChat 15 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -128,6 +134,14 @@ businessChatPrefsVersion = VersionChat 11 contentReportsVersion :: VersionChat contentReportsVersion = VersionChat 12 +-- support sending and receiving group join rejection (XGrpLinkReject) +groupJoinRejectVersion :: VersionChat +groupJoinRejectVersion = VersionChat 14 + +-- support group knocking (MsgScope) +groupKnockingVersion :: VersionChat +groupKnockingVersion = VersionChat 15 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -234,6 +248,12 @@ instance ToJSON SharedMsgId where toJSON = strToJSON toEncoding = strToJEncoding +data MsgScope + = MSMember {memberId :: MemberId} -- Admins can use any member id; members can use only their own id + deriving (Eq, Show) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MS") ''MsgScope) + $(JQ.deriveJSON defaultJSON ''AppMessageJson) data MsgRef = MsgRef @@ -310,7 +330,7 @@ data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMess data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -324,8 +344,10 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json + XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json - XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json + XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json + XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json @@ -334,12 +356,12 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json XGrpMemCon :: MemberId -> ChatMsgEvent 'Json XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented - XGrpMemDel :: MemberId -> ChatMsgEvent 'Json + XGrpMemDel :: MemberId -> Bool -> ChatMsgEvent 'Json XGrpLeave :: ChatMsgEvent 'Json XGrpDel :: ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json - XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> ChatMsgEvent 'Json + XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMsgForward :: MemberId -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json @@ -372,10 +394,10 @@ isForwardedGroupMsg ev = case ev of XMsgReact {} -> True XFileCancel _ -> True XInfo _ -> True - XGrpMemNew _ -> True + XGrpMemNew {} -> True XGrpMemRole {} -> True XGrpMemRestrict {} -> True - XGrpMemDel _ -> True -- TODO there should be a special logic when deleting host member (e.g., host forwards it before deleting connections) + XGrpMemDel {} -> True -- TODO there should be a special logic when deleting host member (e.g., host forwards it before deleting connections) XGrpLeave -> True XGrpDel -> True -- TODO there should be a special logic - host should forward before deleting connections XGrpInfo _ -> True @@ -388,19 +410,34 @@ forwardedGroupMsg msg@ChatMessage {chatMsgEvent} = case encoding @e of _ -> Nothing -- applied after checking forwardedGroupMsg and building list of group members to forward to, see Chat; +-- -- this filters out members if any of forwarded events in batch is an XGrpMemRestrict event referring to them, -- but practically XGrpMemRestrict is not batched with other events so it wouldn't prevent forwarding of other events --- to these members +-- to these members; +-- +-- same for reports (MCReport) - they are not batched with other events, so we can safely filter out +-- members with role less than moderator when forwarding forwardedToGroupMembers :: forall e. MsgEncodingI e => [GroupMember] -> NonEmpty (ChatMessage e) -> [GroupMember] forwardedToGroupMembers ms forwardedMsgs = - filter (\GroupMember {memberId} -> memberId `notElem` restrictMemberIds) ms + filter forwardToMember ms where + forwardToMember GroupMember {memberId, memberRole} = + (memberId `notElem` restrictMemberIds) + && (not hasReport || memberRole >= GRModerator) restrictMemberIds = mapMaybe restrictMemberId $ L.toList forwardedMsgs restrictMemberId ChatMessage {chatMsgEvent} = case encoding @e of SJson -> case chatMsgEvent of XGrpMemRestrict mId _ -> Just mId _ -> Nothing _ -> Nothing + hasReport = any isReportEvent forwardedMsgs + isReportEvent ChatMessage {chatMsgEvent} = case encoding @e of + SJson -> case chatMsgEvent of + XMsgNew mc -> case mcExtMsgContent mc of + ExtMsgContent {content = MCReport {}} -> True + _ -> False + _ -> False + _ -> False data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) @@ -408,6 +445,13 @@ data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json emojiTag :: IsString a => a emojiTag = "emoji" +knownReaction :: MsgReaction -> Either String MsgReaction +knownReaction = \case + r@MREmoji {} -> Right r + MRUnknown {} -> Left "unknown MsgReaction" + +-- parseJSON for MsgReaction parses unknown emoji reactions as MRUnknown with type "emoji", +-- allowing to add new emojis in a backwards compatible way - UI shows them as ? instance FromJSON MsgReaction where parseJSON (J.Object v) = do tag <- v .: "type" @@ -531,13 +575,18 @@ mcExtMsgContent = \case MCComment _ c -> c MCForward c -> c +isMCForward :: MsgContainer -> Bool +isMCForward = \case + MCForward _ -> True + _ -> False + data MsgContent - = MCText Text + = MCText {text :: Text} | MCLink {text :: Text, preview :: LinkPreview} | MCImage {text :: Text, image :: ImageData} | MCVideo {text :: Text, image :: ImageData, duration :: Int} | MCVoice {text :: Text, duration :: Int} - | MCFile Text + | MCFile {text :: Text} | MCReport {text :: Text, reason :: ReportReason} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -578,6 +627,11 @@ isVoice = \case MCVoice {} -> True _ -> False +isReport :: MsgContent -> Bool +isReport = \case + MCReport {} -> True + _ -> False + msgContentTag :: MsgContent -> MsgContentTag msgContentTag = \case MCText _ -> MCText_ @@ -589,9 +643,24 @@ msgContentTag = \case MCReport {} -> MCReport_ MCUnknown {tag} -> MCUnknown_ tag -data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} +data ExtMsgContent = ExtMsgContent + { content :: MsgContent, + -- the key used in mentions is a locally (per message) unique display name of member. + -- Suffixes _1, _2 should be appended to make names locally unique. + -- It should be done in the UI, as they will be part of the text, and validated in the API. + mentions :: Map MemberName MsgMention, + file :: Maybe FileInvitation, + ttl :: Maybe Int, + live :: Maybe Bool, + scope :: Maybe MsgScope + } deriving (Eq, Show) +data MsgMention = MsgMention {memberId :: MemberId} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''MsgMention) + $(JQ.deriveJSON defaultJSON ''QuotedMsg) -- this limit reserves space for metadata in forwarded messages @@ -657,10 +726,17 @@ parseMsgContainer v = <|> (MCForward <$> ((v .: "forward" :: JT.Parser J.Object) *> mc)) <|> MCSimple <$> mc where - mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" <*> v .:? "ttl" <*> v .:? "live" + mc = do + content <- v .: "content" + file <- v .:? "file" + ttl <- v .:? "ttl" + live <- v .:? "live" + mentions <- fromMaybe M.empty <$> (v .:? "mentions") + scope <- v .:? "scope" + pure ExtMsgContent {content, mentions, file, ttl, live, scope} extMsgContent :: MsgContent -> Maybe FileInvitation -> ExtMsgContent -extMsgContent mc file = ExtMsgContent mc file Nothing Nothing +extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing Nothing justTrue :: Bool -> Maybe Bool justTrue True = Just True @@ -709,7 +785,12 @@ msgContainerJSON = \case MCSimple mc -> o $ msgContent mc where o = JM.fromList - msgContent (ExtMsgContent c file ttl live) = ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) ["content" .= c] + msgContent ExtMsgContent {content, mentions, file, ttl, live, scope} = + ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) $ ("scope" .=? scope) ["content" .= content] + +nonEmptyMap :: Map k v -> Maybe (Map k v) +nonEmptyMap m = if M.null m then Nothing else Just m +{-# INLINE nonEmptyMap #-} instance ToJSON MsgContent where toJSON = \case @@ -756,7 +837,9 @@ data CMEventTag (e :: MsgEncoding) where XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json XGrpLinkInv_ :: CMEventTag 'Json + XGrpLinkReject_ :: CMEventTag 'Json XGrpLinkMem_ :: CMEventTag 'Json + XGrpLinkAcpt_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -807,7 +890,9 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" XGrpLinkInv_ -> "x.grp.link.inv" + XGrpLinkReject_ -> "x.grp.link.reject" XGrpLinkMem_ -> "x.grp.link.mem" + XGrpLinkAcpt_ -> "x.grp.link.acpt" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -859,7 +944,9 @@ instance StrEncoding ACMEventTag where "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ "x.grp.link.inv" -> XGrpLinkInv_ + "x.grp.link.reject" -> XGrpLinkReject_ "x.grp.link.mem" -> XGrpLinkMem_ + "x.grp.link.acpt" -> XGrpLinkAcpt_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -907,8 +994,10 @@ toCMEventTag msg = case msg of XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ XGrpLinkInv _ -> XGrpLinkInv_ + XGrpLinkReject _ -> XGrpLinkReject_ XGrpLinkMem _ -> XGrpLinkMem_ - XGrpMemNew _ -> XGrpMemNew_ + XGrpLinkAcpt {} -> XGrpLinkAcpt_ + XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ XGrpMemFwd _ _ -> XGrpMemFwd_ @@ -917,12 +1006,12 @@ toCMEventTag msg = case msg of XGrpMemRestrict _ _ -> XGrpMemRestrict_ XGrpMemCon _ -> XGrpMemCon_ XGrpMemConAll _ -> XGrpMemConAll_ - XGrpMemDel _ -> XGrpMemDel_ + XGrpMemDel {} -> XGrpMemDel_ XGrpLeave -> XGrpLeave_ XGrpDel -> XGrpDel_ XGrpInfo _ -> XGrpInfo_ XGrpPrefs _ -> XGrpPrefs_ - XGrpDirectInv _ _ -> XGrpDirectInv_ + XGrpDirectInv {} -> XGrpDirectInv_ XGrpMsgForward {} -> XGrpMsgForward_ XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ @@ -994,7 +1083,14 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do msg = \case XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" - XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> opt "ttl" <*> opt "live" + XMsgUpdate_ -> do + msgId' <- p "msgId" + content <- p "content" + mentions <- fromMaybe M.empty <$> opt "mentions" + ttl <- opt "ttl" + live <- opt "live" + scope <- opt "scope" + pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> p "reaction" <*> p "add" @@ -1008,8 +1104,10 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" + XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" - XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" + XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" + XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" XGrpMemFwd_ -> XGrpMemFwd <$> p "memberInfo" <*> p "memberIntro" @@ -1018,12 +1116,12 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions" XGrpMemCon_ -> XGrpMemCon <$> p "memberId" XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId" - XGrpMemDel_ -> XGrpMemDel <$> p "memberId" + XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") XGrpLeave_ -> pure XGrpLeave XGrpDel_ -> pure XGrpDel XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" - XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" + XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" XGrpMsgForward_ -> XGrpMsgForward <$> p "memberId" <*> p "msg" <*> p "msgTs" XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" @@ -1056,7 +1154,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgUpdate msgId' content ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content] + XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] @@ -1070,8 +1168,10 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] + XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] - XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] + XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] + XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] XGrpMemFwd memInfo memIntro -> o ["memberInfo" .= memInfo, "memberIntro" .= memIntro] @@ -1080,12 +1180,12 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions] XGrpMemCon memId -> o ["memberId" .= memId] XGrpMemConAll memId -> o ["memberId" .= memId] - XGrpMemDel memId -> o ["memberId" .= memId] + XGrpMemDel memId messages -> o $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] XGrpLeave -> JM.empty XGrpDel -> JM.empty XGrpInfo p -> o ["groupProfile" .= p] XGrpPrefs p -> o ["groupPreferences" .= p] - XGrpDirectInv connReq content -> o $ ("content" .=? content) ["connReq" .= connReq] + XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] XGrpMsgForward memberId msg msgTs -> o ["memberId" .= memberId, "msg" .= msg, "msgTs" .= msgTs] XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index a2eaad11fe..92b499d8ef 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -58,7 +58,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) +import Simplex.Messaging.Transport (TLS, TransportPeer (..), closeConnection, tlsUniq) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 3, 0, 1] +minRemoteCtrlVersion = AppVersion [6, 4, 0, 2] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 1] +minRemoteHostVersion = AppVersion [6, 4, 0, 2] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -184,7 +184,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) - waitForHostSession :: Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> CM () + waitForHostSession :: Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS 'TServer, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> CM () waitForHostSession remoteHost_ rhKey sseq rcAddr_ rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId @@ -192,7 +192,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ - toView CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} + toView CEvtRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello @@ -203,7 +203,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' - toView $ CRNewRemoteHost rhi + toView $ CEvtNewRemoteHost rhi -- set up HTTP transport and remote profile protocol disconnected <- toIO $ onDisconnected rhKey' sseq httpClient <- liftError' (httpError remoteHostId) $ attachRevHTTP2Client disconnected tls @@ -213,7 +213,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host - toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} + toView $ CEvtRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} upsertRemoteHost :: RCHostPairing -> Maybe RemoteHostInfo -> Maybe RCCtrlAddress -> Text -> SessionSeq -> RemoteHostSessionState -> CM RemoteHostInfo upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ rcAddr_ hostDeviceName sseq state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ @@ -235,7 +235,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do oq <- asks outputQ forever $ do r_ <- liftRH rhId $ remoteRecv rhClient 10000000 - forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) + forM r_ $ \r -> atomically $ writeTBQueue oq (Just rhId, r) httpError :: RemoteHostId -> HTTP2ClientError -> ChatError httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow @@ -271,7 +271,7 @@ cancelRemoteHostSession handlerInfo_ rhKey = do forM_ deregistered $ \session -> do liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) forM_ (snd <$> handlerInfo_) $ \rhStopReason -> - toView CRRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} + toView CEvtRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} where handlingError = isJust handlerInfo_ remoteHostId_ = case rhKey of @@ -370,16 +370,17 @@ processRemoteCommand :: RemoteHostId -> RemoteHostClient -> ChatCommand -> ByteS processRemoteCommand remoteHostId c cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f SendImage chatName f -> sendFile "/img" chatName f - _ -> liftRH remoteHostId $ remoteSend c s + _ -> chatRemoteSend s where sendFile cmdName chatName (CryptoFile path cfArgs) = do -- don't encrypt in host if already encrypted locally CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption - liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + chatRemoteSend $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] cryptoFileStr CryptoFile {filePath, cryptoArgs} = maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs <> encodeUtf8 (T.pack filePath) + chatRemoteSend = either throwError pure <=< liftRH remoteHostId . remoteSend c liftRH :: RemoteHostId -> ExceptT RemoteProtocolError IO a -> CM a liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) @@ -417,7 +418,7 @@ findKnownRemoteCtrl = do Just rc -> pure rc atomically $ putTMVar foundCtrl (rc, inv) let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_ - toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} + toView CEvtRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionSearching {action, foundCtrl} _ -> Left $ ChatErrorRemoteCtrl RCEBadState @@ -473,7 +474,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) where validateRemoteCtrl RCInvitation {idkey} RemoteCtrl {ctrlPairing = RCCtrlPairing {idPubKey}} = unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity - waitForCtrlSession :: Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> CM () + waitForCtrlSession :: Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS 'TClient, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> CM () waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq @@ -482,7 +483,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) let remoteCtrlId_ = remoteCtrlId' <$> rc_ in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState - toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} + toView CEvtRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} checkAppVersion CtrlAppInfo {appVersionRange} = case compatibleAppVersion hostAppVersionRange appVersionRange of Just (AppCompatible v) -> pure v @@ -496,7 +497,7 @@ parseCtrlAppInfo :: JT.Value -> CM CtrlAppInfo parseCtrlAppInfo ctrlAppInfo = do liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo -handleRemoteCommand :: (ByteString -> CM' ChatResponse) -> RemoteCrypto -> TBQueue ChatResponse -> HTTP2Request -> CM' () +handleRemoteCommand :: (ByteString -> CM' (Either ChatError ChatResponse)) -> RemoteCrypto -> TBQueue (Either ChatError ChatEvent) -> HTTP2Request -> CM' () handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftIO (tryRemoteError' parseRequest) >>= \case @@ -510,7 +511,7 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque parseRequest = do (rfKN, header, getNext) <- parseDecryptHTTP2Body encryption request reqBody (rfKN,getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecode header) - replyError = reply . RRChatResponse . CRChatCmdError Nothing + replyError = reply . RRChatResponse . RRError processCommand :: User -> C.SbKeyNonce -> GetChunk -> RemoteCommand -> CM () processCommand user rfKN getNext = \case RCSend {command} -> lift $ handleSend execChatCommand command >>= reply @@ -527,7 +528,7 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque send resp attach sfKN send flush - Left e -> toView' . CRChatError Nothing . ChatErrorRemoteCtrl $ RCEProtocolError e + Left e -> eToView' $ ChatErrorRemoteCtrl $ RCEProtocolError e takeRCStep :: RCStepTMVar a -> CM a takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar @@ -549,17 +550,17 @@ tryRemoteError' :: ExceptT RemoteProtocolError IO a -> IO (Either RemoteProtocol tryRemoteError' = tryAllErrors' (RPEException . tshow) {-# INLINE tryRemoteError' #-} -handleSend :: (ByteString -> CM' ChatResponse) -> Text -> CM' RemoteResponse +handleSend :: (ByteString -> CM' (Either ChatError ChatResponse)) -> Text -> CM' RemoteResponse handleSend execChatCommand command = do logDebug $ "Send: " <> tshow command -- execChatCommand checks for remote-allowed commands -- convert errors thrown in execChatCommand into error responses to prevent aborting the protocol wrapper - RRChatResponse <$> execChatCommand (encodeUtf8 command) + RRChatResponse . eitherToResult <$> execChatCommand (encodeUtf8 command) -handleRecv :: Int -> TBQueue ChatResponse -> IO RemoteResponse +handleRecv :: Int -> TBQueue (Either ChatError ChatEvent) -> IO RemoteResponse handleRecv time events = do logDebug $ "Recv: " <> tshow time - RRChatEvent <$> (timeout time . atomically $ readTBQueue events) + RRChatEvent . fmap eitherToResult <$> (timeout time . atomically $ readTBQueue events) -- TODO this command could remember stored files and return IDs to allow removing files that are not needed. -- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files). @@ -614,7 +615,7 @@ remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} -- | Take a look at emoji of tlsunique, commit pairing, and start session server -verifyRemoteCtrlSession :: (ByteString -> CM' ChatResponse) -> Text -> CM RemoteCtrlInfo +verifyRemoteCtrlSession :: (ByteString -> CM' (Either ChatError ChatResponse)) -> Text -> CM RemoteCtrlInfo verifyRemoteCtrlSession execChatCommand sessCode' = do (sseq, client, ctrlName, sessionCode, vars) <- chatReadVar remoteCtrlSession >>= \case @@ -675,7 +676,7 @@ cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do forM_ session_ $ \session -> do liftIO $ cancelRemoteCtrl handlingError session forM_ (snd <$> handlerInfo_) $ \rcStopReason -> - toView CRRemoteCtrlStopped {rcsState = rcsSessionState session, rcStopReason} + toView CEvtRemoteCtrlStopped {rcsState = rcsSessionState session, rcStopReason} where handlingError = isJust handlerInfo_ diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 00fc56f897..207eade665 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -16,11 +16,11 @@ import Control.Monad.Except import Control.Monad.Reader import Crypto.Hash (SHA512) import qualified Crypto.Hash as CH -import Data.Aeson ((.=)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Key as JK import qualified Data.Aeson.KeyMap as JM -import Data.Aeson.TH (deriveJSON) +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.ByteArray as BA import Data.ByteString (ByteString) @@ -42,7 +42,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Lazy (LazyByteString) import Simplex.Messaging.Encoding -import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TSbChainKeys) import Simplex.Messaging.Transport.Buffer (getBuffered) @@ -64,16 +64,40 @@ data RemoteCommand deriving (Show) data RemoteResponse - = RRChatResponse {chatResponse :: ChatResponse} - | RRChatEvent {chatEvent :: Maybe ChatResponse} -- 'Nothing' on poll timeout + = RRChatResponse {chatResponse :: RRResult ChatResponse} + | RRChatEvent {chatEvent :: Maybe (RRResult ChatEvent)} -- 'Nothing' on poll timeout | RRFileStored {filePath :: String} | RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- The protocol error happened on the server side deriving (Show) +data RRResult r + = RRResult {result :: r} + | RRError {error :: ChatError} + deriving (Show) + +resultToEither :: RRResult r -> Either ChatError r +resultToEither = \case + RRResult r -> Right r + RRError e -> Left e +{-# INLINE resultToEither #-} + +eitherToResult :: Either ChatError r -> RRResult r +eitherToResult = either RRError RRResult +{-# INLINE eitherToResult #-} + +$(pure []) + -- Force platform-independent encoding as the types aren't UI-visible -$(deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand) -$(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) +instance ToJSON r => ToJSON (RRResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''RRResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''RRResult) + +instance FromJSON r => FromJSON (RRResult r) where + parseJSON = $(JQ.mkParseJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''RRResult) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand) +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) -- * Client side / desktop @@ -109,16 +133,16 @@ closeRemoteHostClient RemoteHostClient {httpClient} = closeHTTP2Client httpClien -- ** Commands -remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO (Either ChatError ChatResponse) remoteSend c cmd = sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case - RRChatResponse cr -> pure cr + RRChatResponse cr -> pure $ resultToEither cr r -> badResponse r -remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe (Either ChatError ChatEvent)) remoteRecv c ms = sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case - RRChatEvent cr_ -> pure cr_ + RRChatEvent cEvt_ -> pure $ resultToEither <$> cEvt_ r -> badResponse r remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath @@ -172,7 +196,7 @@ convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value convertJSON _remote@PEKotlin _local@PEKotlin = id convertJSON PESwift PESwift = id convertJSON PESwift PEKotlin = owsf2tagged -convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by handshake +convertJSON PEKotlin PESwift = Prelude.error "unsupported convertJSON: K/S" -- guarded by handshake -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs index 4df5bcac2a..20484c0b10 100644 --- a/src/Simplex/Chat/Remote/RevHTTP.hs +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -8,18 +8,18 @@ module Simplex.Chat.Remote.RevHTTP where -import Simplex.Messaging.Transport (TLS) +import Simplex.Messaging.Transport (TLS, TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.RemoteControl.Discovery -attachRevHTTP2Client :: IO () -> TLS -> IO (Either HTTP2ClientError HTTP2Client) +attachRevHTTP2Client :: IO () -> TLS 'TServer -> IO (Either HTTP2ClientError HTTP2Client) attachRevHTTP2Client disconnected = attachHTTP2Client config ANY_ADDR_V4 "0" disconnected defaultHTTP2BufferSize where config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} -attachHTTP2Server :: TLS -> (HTTP2Request -> IO ()) -> IO () +attachHTTP2Server :: TLS 'TClient -> (HTTP2Request -> IO ()) -> IO () attachHTTP2Server tls processRequest = runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId sessionALPN r sendResponse -> do reqBody <- getHTTP2Body r doNotPrefetchHead diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index a5396e7945..defbe7e72c 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -25,7 +25,7 @@ import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) -import Simplex.Messaging.Transport (TLS (..), TSbChainKeys (..)) +import Simplex.Messaging.Transport (TLS (..), TSbChainKeys (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.TMap as TM import Simplex.RemoteControl.Client @@ -102,11 +102,11 @@ data RHPendingSession = RHPendingSession data RemoteHostSession = RHSessionStarting | RHSessionConnecting {invitation :: Text, rhPendingSession :: RHPendingSession} - | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession} - | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} + | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS 'TServer, rhPendingSession :: RHPendingSession} + | RHSessionConfirmed {tls :: TLS 'TServer, rhPendingSession :: RHPendingSession} | RHSessionConnected { rchClient :: RCHostClient, - tls :: TLS, + tls :: TLS 'TServer, rhClient :: RemoteHostClient, pollAction :: Async (), storePath :: FilePath @@ -128,7 +128,7 @@ rhsSessionState = \case RHSessionConfirmed {tls} -> RHSConfirmed {sessionCode = tlsSessionCode tls} RHSessionConnected {tls} -> RHSConnected {sessionCode = tlsSessionCode tls} -tlsSessionCode :: TLS -> Text +tlsSessionCode :: TLS p -> Text tlsSessionCode = verificationCode . tlsUniq data RemoteProtocolError diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 03b4d7a640..a41641f88d 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -6,6 +6,7 @@ module Simplex.Chat.Store ChatLockEntity (..), UserMsgReceiptSettings (..), UserContactLink (..), + GroupLinkInfo (..), AutoAccept (..), createChatStore, migrations, -- used in tests diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 589b8e39f2..b69f6f646c 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 @@ -134,16 +136,21 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -202,6 +209,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 44ee662c75..4de832a8b1 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -35,7 +35,6 @@ module Simplex.Chat.Store.Direct deleteContactFiles, deleteContact, deleteContactWithoutGroups, - setContactDeleted, getDeletedContacts, getContactByName, getContact, @@ -46,7 +45,6 @@ module Simplex.Chat.Store.Direct updateContactConnectionAlias, updatePCCIncognito, deletePCCIncognitoProfile, - updateContactUsed, updateContactUnreadChat, setUserChatsRead, updateContactStatus, @@ -102,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 @@ -124,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 = ? @@ -150,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 @@ -166,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 @@ -216,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 @@ -225,25 +223,25 @@ 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 createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createDirectContact :: DB.Connection -> User -> Connection -> Profile -> Bool -> ExceptT StoreError IO Contact -createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} contactUsed = do +createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact +createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do currentTs <- liftIO getCurrentTime - (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs contactUsed + (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs @@ -255,7 +253,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p profile, activeConn = Just conn, viaGroup = Nothing, - contactUsed, + contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, @@ -295,7 +293,7 @@ deleteContact db user@User {userId} ct@Contact {contactId, localDisplayName, act assertNotUser db user ct liftIO $ do DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) + ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE contact_id = ? LIMIT 1" (Only contactId) if isNothing ctMember then do deleteContactProfile_ db userId contactId @@ -323,13 +321,7 @@ deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDis forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId -setContactDeleted :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () -setContactDeleted db user@User {userId} ct@Contact {contactId} = do - assertNotUser db user ct - liftIO $ do - currentTs <- getCurrentTime - DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) - +-- TODO remove in future versions: only used for legacy contact cleanup getDeletedContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact] getDeletedContacts db vr user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) @@ -471,11 +463,6 @@ deletePCCIncognitoProfile db User {userId} profileId = |] (userId, profileId) -updateContactUsed :: DB.Connection -> User -> Contact -> IO () -updateContactUsed db User {userId} Contact {contactId} = do - updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (updatedAt, userId, contactId) - updateContactUnreadChat :: DB.Connection -> User -> Contact -> Bool -> IO () updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime @@ -917,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 = ? @@ -1002,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/Files.hs b/src/Simplex/Chat/Store/Files.hs index 95e169e400..81dbb4ca54 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -371,9 +371,9 @@ getXFTPRcvFileDBIds db aRcvFileId = toFileRef :: (FileTransferId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (Maybe ChatRef, FileTransferId) toFileRef = \case - (fileId, Just contactId, Nothing, Nothing) -> Right (Just $ ChatRef CTDirect contactId, fileId) - (fileId, Nothing, Just groupId, Nothing) -> Right (Just $ ChatRef CTGroup groupId, fileId) - (fileId, Nothing, Nothing, Just folderId) -> Right (Just $ ChatRef CTLocal folderId, fileId) + (fileId, Just contactId, Nothing, Nothing) -> Right (Just $ ChatRef CTDirect contactId Nothing, fileId) + (fileId, Nothing, Just groupId, Nothing) -> Right (Just $ ChatRef CTGroup groupId Nothing, fileId) + (fileId, Nothing, Nothing, Just folderId) -> Right (Just $ ChatRef CTLocal folderId Nothing, fileId) (fileId, _, _, _) -> Right (Nothing, fileId) updateFileCancelled :: MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> IO () @@ -444,8 +444,8 @@ getChatRefByFileId db user fileId = liftIO (lookupChatRefByFileId db user fileId lookupChatRefByFileId :: DB.Connection -> User -> Int64 -> IO (Maybe ChatRef) lookupChatRefByFileId db User {userId} fileId = getChatRef <&> \case - [(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId - [(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId + [(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId Nothing + [(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId Nothing _ -> Nothing where getChatRef = diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 589e220690..f3661699cb 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -33,11 +33,13 @@ module Simplex.Chat.Store.Groups createGroupInvitation, deleteContactCardKeepConn, createGroupInvitedViaLink, + createGroupRejectedViaLink, setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, getGroupInfo, getGroupInfoByUserContactLinkConnReq, + getGroupInfoViaUserShortLink, getGroupInfoByGroupLinkHash, updateGroupProfile, updateGroupPreferences, @@ -47,26 +49,28 @@ module Simplex.Chat.Store.Groups getActiveMembersByName, getGroupInfoByName, getGroupMember, + getMentionedGroupMember, + getMentionedMemberByMemberId, getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, - deleteGroupConnectionsAndFiles, - deleteGroupItemsAndMembers, + deleteGroupChatItems, + deleteGroupMembers, + cleanupHostGroupLinkConn, deleteGroup, - getUserGroups, + getUserGroupsToSubscribe, getUserGroupDetails, getUserGroupsWithSummary, getGroupSummary, getContactGroupPreferences, - checkContactHasGroups, getGroupInvitation, createNewContactMember, createNewContactMemberAsync, - createAcceptedMember, - createAcceptedMemberConnection, + createJoiningMember, + createJoiningMemberConnection, createBusinessRequestGroup, getContactViaMember, setNewContactMemberConnRequest, @@ -75,6 +79,11 @@ module Simplex.Chat.Store.Groups createMemberConnectionAsync, updateGroupMemberStatus, updateGroupMemberStatusById, + updateGroupMemberAccepted, + deleteGroupMemberSupportChat, + updateGroupMembersRequireAttention, + decreaseGroupMembersRequireAttention, + increaseGroupMembersRequireAttention, createNewGroupMember, checkGroupMemberHasItems, deleteGroupMember, @@ -84,6 +93,7 @@ module Simplex.Chat.Store.Groups updateIntroStatus, saveIntroInvitation, getIntroduction, + getIntroducedGroupMemberIds, getForwardIntroducedMembers, getForwardInvitedMembers, createIntroReMember, @@ -107,7 +117,6 @@ module Simplex.Chat.Store.Groups updateGroupMemberSettings, updateGroupMemberBlocked, getXGrpMemIntroContDirect, - getXGrpMemIntroContGroup, getHostConnId, createMemberContact, getMemberContact, @@ -147,42 +156,46 @@ import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (groupForwardVersion) +import Simplex.Chat.Protocol (MsgMention (..), groupForwardVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared 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) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else 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)) +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, Maybe UTCTime) 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 $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) +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, supportChatLastMsgFromMemberTs)) = + 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, supportChatLastMsgFromMemberTs)) 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 @@ -243,12 +256,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, GroupLinkId, 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_id, 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, gLinkId, mRole_) = (linkId, CCLink cReq shortLink, gLinkId, fromMaybe GRMember mRole_) getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId) getGroupLinkId db User {userId} GroupInfo {groupId} = @@ -269,16 +282,21 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -308,7 +326,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do -- | creates completely new group with a single member - the current user createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile @@ -316,8 +334,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -341,7 +359,6 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc businessChat = Nothing, fullGroupPreferences, membership, - hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, @@ -350,22 +367,23 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + membersRequireAttention = 0 } -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do +createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db vr user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership - MemberIdRole {memberId = memberId', memberRole = memberRole'} = invitedMember - liftIO . when (memberId /= memberId' || memberRole /= memberRole') $ - DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (memberId', memberRole', groupMemberId) + MemberIdRole {memberId = invMemberId, memberRole = memberRole'} = invitedMember + liftIO . when (memberId /= invMemberId || memberRole /= memberRole') $ + DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (invMemberId, memberRole', groupMemberId) gInfo' <- if p' == groupProfile then pure gInfo @@ -378,7 +396,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation_ = do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences ExceptT $ withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do @@ -386,18 +404,18 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db [sql| INSERT INTO groups - (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, connRequest, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange @@ -412,7 +430,6 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ businessChat = Nothing, fullGroupPreferences, membership, - hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, @@ -421,7 +438,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + membersRequireAttention = 0 }, groupMemberId ) @@ -466,7 +484,10 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberContactId = Just $ contactId' userOrContact, memberContactProfileId = localProfileId (profile' userOrContact), activeConn = Nothing, - memberChatVRange + memberChatVRange, + createdAt, + updatedAt = createdAt, + supportChat = Nothing } where memberChatVRange@(VersionRange minV maxV) = vr @@ -512,42 +533,57 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupInvitedViaLink +createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do + let fromMemberProfile = profileFromName fromMemberName + initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted + createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus + +createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupRejectedViaLink db vr user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do + let fromMemberProfile = profileFromName $ nameFromMemberId memberId + createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + +createGroupViaLink' :: DB.Connection -> VersionRangeChat -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupViaLink' db vr user@User {userId, userContactId} Connection {connId, customUserProfileId} - GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, business} = do + fromMember + fromMemberProfile + invitedMember + groupProfile + business + membershipStatus = do currentTs <- liftIO getCurrentTime groupId <- insertGroup_ currentTs hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs vr + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId currentTs vr liftIO $ setViaGroupLinkHash db groupId connId (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where insertGroup_ currentTs = ExceptT $ do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db [sql| INSERT INTO groups - (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db insertHost_ currentTs groupId = do - let fromMemberProfile = profileFromName fromMemberName (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember liftIO $ do @@ -588,33 +624,120 @@ getGroup db vr user groupId = do members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members -deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () -deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do - forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m) - DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ?" (userId, groupId) - -deleteGroupItemsAndMembers :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () -deleteGroupItemsAndMembers db user@User {userId} g@GroupInfo {groupId} members = do - DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) - void $ runExceptT cleanupHostGroupLinkConn_ -- to allow repeat connection via the same group link if one was used - DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId) - forM_ members $ cleanupMemberProfileAndName_ db user - forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId +getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup +getGroupToSubscribe db User {userId, userContactId} groupId = do + shortInfo <- getGroupInfoToSubscribe + members <- liftIO getGroupMembersToSubscribe + pure $ ShortGroup shortInfo members where - cleanupHostGroupLinkConn_ = do - hostId <- getHostMemberId_ db user groupId - liftIO $ - DB.execute + getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo + getGroupInfoToSubscribe = ExceptT $ do + firstRow toInfo (SEGroupNotFound groupId) $ + DB.query db [sql| - UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL - WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( - SELECT contact_id - FROM group_members - WHERE user_id = ? AND group_member_id = ? - ) + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) |] - (userId, userId, hostId) + (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo + toInfo (groupName, membershipStatus) = + ShortGroupInfo groupId groupName membershipStatus + getGroupMembersToSubscribe :: IO [ShortGroupMember] + getGroupMembersToSubscribe = do + map toShortMember + <$> DB.query + db + [sql| + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status NOT IN (?,?,?) + |] + (userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember + toShortMember (groupMemberId, localDisplayName, agentConnId) = + ShortGroupMember groupMemberId groupId localDisplayName agentConnId + +deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () +deleteGroupChatItems db User {userId} GroupInfo {groupId} = + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) + +deleteGroupMembers :: DB.Connection -> User -> GroupInfo -> IO () +deleteGroupMembers db User {userId} GroupInfo {groupId} = do + DB.execute_ db "DROP TABLE IF EXISTS temp_delete_members" +#if defined(dbPostgres) + DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id BIGINT, member_profile_id BIGINT, local_display_name TEXT)" +#else + DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" +#endif + DB.execute + db + [sql| + INSERT INTO temp_delete_members (contact_profile_id, member_profile_id, local_display_name) + SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? + |] + (Only groupId) + DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId) + DB.execute + db + [sql| + DELETE FROM contact_profiles + WHERE + user_id = ? + AND (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_members) + OR contact_profile_id IN (SELECT member_profile_id FROM temp_delete_members WHERE member_profile_id IS NOT NULL)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections) + |] + (Only userId) + DB.execute + db + [sql| + DELETE FROM display_names + WHERE + user_id = ? + AND local_display_name IN (SELECT local_display_name FROM temp_delete_members) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests) + |] + (Only userId) + DB.execute_ db "DROP TABLE temp_delete_members" + +-- to allow repeat connection via the same group link if one was used +cleanupHostGroupLinkConn :: DB.Connection -> User -> GroupInfo -> IO () +cleanupHostGroupLinkConn db user@User {userId} GroupInfo {groupId} = do + runExceptT (getHostMemberId_ db user groupId) >>= \case + Left _ -> pure () + Right hostId -> + DB.execute + db + [sql| + UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + |] + (userId, userId, hostId) deleteGroup :: DB.Connection -> User -> GroupInfo -> IO () deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do @@ -637,10 +760,10 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroups :: DB.Connection -> VersionRangeChat -> User -> IO [Group] -getUserGroups db vr user@User {userId} = do +getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] +getUserGroupsToSubscribe db user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) - rights <$> mapM (runExceptT . getGroup db vr user) groupIds + rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do @@ -651,10 +774,13 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, - mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -688,9 +814,9 @@ getGroupSummary db User {userId} groupId = do JOIN group_members m USING (group_id) WHERE g.user_id = ? AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?) + AND m.member_status NOT IN (?,?,?,?,?) |] - (userId, groupId, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) + (userId, groupId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] @@ -707,10 +833,6 @@ getContactGroupPreferences db User {userId} Contact {contactId} = do |] (userId, contactId) -checkContactHasGroups :: DB.Connection -> User -> Contact -> IO (Maybe GroupId) -checkContactHasGroups db User {userId} Contact {contactId} = - maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) - getGroupInfoByName :: DB.Connection -> VersionRangeChat -> User -> GroupName -> ExceptT StoreError IO GroupInfo getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName @@ -722,6 +844,8 @@ groupMemberQuery = SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -743,6 +867,39 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) +getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention +getMentionedGroupMember db User {userId} groupId gmId = + ExceptT $ + firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") + (groupId, gmId, userId) + +getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MsgMention -> IO CIMention +getMentionedMemberByMemberId db User {userId} groupId MsgMention {memberId} = + fmap (fromMaybe mentionedMember) $ + maybeFirstRow toMentionedMember $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") + (groupId, memberId, userId) + where + mentionedMember = CIMention {memberId, memberRef = Nothing} + +mentionedMemberQuery :: Query +mentionedMemberQuery = + [sql| + SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + |] + +toMentionedMember :: (GroupMemberId, MemberId, GroupMemberRole, Text, Maybe Text) -> CIMention +toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) = + let memberRef = Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} + in CIMention {memberId, memberRef} + getGroupMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMemberById db vr user@User {userId} groupMemberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ @@ -772,7 +929,7 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = map (toContactMember vr user) <$> DB.query db - (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?)") + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] @@ -856,7 +1013,10 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, memberContactId = Just contactId, memberContactProfileId = localProfileId profile, activeConn = Nothing, - memberChatVRange = peerChatVRange + memberChatVRange = peerChatVRange, + createdAt, + updatedAt = createdAt, + supportChat = Nothing } where insertMember_ = @@ -899,14 +1059,15 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) -createAcceptedMember +createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} UserContactRequest {cReqChatVRange, localDisplayName, profileId} - memberRole = + memberRole + memberStatus = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt @@ -924,13 +1085,13 @@ createAcceptedMember peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) :. (minV, maxV) ) -createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () -createAcceptedMemberConnection +createJoiningMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createJoiningMemberConnection db user@User {userId} (cmdId, agentConnId) @@ -1058,6 +1219,84 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do |] (memStatus, currentTs, userId, groupMemberId) +updateGroupMemberAccepted :: DB.Connection -> User -> GroupMember -> GroupMemberStatus -> GroupMemberRole -> IO GroupMember +updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status role = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET member_status = ?, member_role = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + |] + (status, role, currentTs, userId, groupMemberId) + pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} + +deleteGroupMemberSupportChat :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) +deleteGroupMemberSupportChat db user g m@GroupMember {groupMemberId} = do + let requiredAttention = gmRequiresAttention m + currentTs <- getCurrentTime + DB.execute + db + [sql| + DELETE FROM chat_items + WHERE group_scope_group_member_id = ? + |] + (Only groupMemberId) + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = NULL, + support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0, + support_chat_last_msg_from_member_ts = NULL, + updated_at = ? + WHERE group_member_id = ? + |] + (currentTs, groupMemberId) + let m' = m {supportChat = Nothing, updatedAt = currentTs} + g' <- if requiredAttention + then decreaseGroupMembersRequireAttention db user g + else pure g + pure (g', m') + +updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo +updateGroupMembersRequireAttention db user g member member' + | nowRequires && not didRequire = + increaseGroupMembersRequireAttention db user g + | not nowRequires && didRequire = + decreaseGroupMembersRequireAttention db user g + | otherwise = pure g + where + didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + +decreaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo +decreaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention - 1} + +increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo +increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention + 1} + -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do @@ -1138,7 +1377,10 @@ createNewMember_ memberContactId, memberContactProfileId, activeConn, - memberChatVRange + memberChatVRange, + createdAt, + updatedAt = createdAt, + supportChat = Nothing } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1236,8 +1478,8 @@ saveIntroInvitation db reMember toMember introInv@IntroInvitation {groupConnReq} (GMIntroInvReceived, groupConnReq, directConnReq introInv, currentTs, introId intro) pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} -saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> IO () -saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} = do +saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> GroupMemberStatus -> IO () +saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} newMemberStatus = do currentTs <- getCurrentTime DB.execute db @@ -1249,7 +1491,7 @@ saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnRe updated_at = ? WHERE group_member_id = ? |] - (GSMemIntroInvited, groupConnReq, directConnReq, currentTs, groupMemberId) + (newMemberStatus, groupConnReq, directConnReq, currentTs, groupMemberId) getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro getIntroduction db reMember toMember = ExceptT $ do @@ -1269,6 +1511,14 @@ getIntroduction db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound +getIntroducedGroupMemberIds :: DB.Connection -> GroupMember -> IO [GroupMemberId] +getIntroducedGroupMemberIds db invitee = + map fromOnly <$> + DB.query + db + "SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ?" + (Only $ groupMemberId' invitee) + getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] getForwardIntroducedMembers db vr user invitee highlyAvailable = do memberIds <- map fromOnly <$> query @@ -1309,33 +1559,23 @@ getForwardInvitedMembers db vr user forwardMember highlyAvailable = do WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db user@User {userId} - gInfo@GroupInfo {groupId} + gInfo _host@GroupMember {memberContactId, activeConn} chatV memInfo@(MemberInfo _ _ memChatVRange memberProfile) memRestrictions_ (groupCmdId, groupAgentConnId) - directConnIds - customUserProfileId subMode = do let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn memRestriction = restriction <$> memRestrictions_ currentTs <- liftIO getCurrentTime - newMember <- case directConnIds of - Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId ConnNew chatV mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff - liftIO $ setCommandConnId db user directCmdId directConnId - (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs False - liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId) - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Just contactId, memProfileId} - Nothing -> do - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode @@ -1393,16 +1633,21 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -1448,7 +1693,7 @@ getViaGroupContact db vr user@User {userId} GroupMember {groupMemberId} = do maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs @@ -1466,14 +1711,14 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, db [sql| UPDATE group_profiles - SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, updated_at = ? + SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ? ) |] - (newName, fullName, description, image, groupPreferences, currentTs, userId, groupId) + (newName, fullName, description, image, groupPreferences, memberAdmission, currentTs, userId, groupId) updateGroup_ ldn currentTs = do DB.execute db @@ -1511,14 +1756,14 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName DB.query db [sql| - SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences + SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) - toGroupProfile (displayName, fullName, description, image, groupPreferences) = - GroupProfile {displayName, fullName, description, image, groupPreferences} + toGroupProfile (displayName, fullName, description, image, groupPreferences, memberAdmission) = + GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do @@ -1531,8 +1776,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| @@ -1543,6 +1789,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_ <- @@ -1762,12 +2028,6 @@ mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keep assertNotUser db user fromCt liftIO $ do currentTs <- getCurrentTime - -- next query fixes incorrect unused contacts deletion - when (contactDirect toCt && not (contactUsed toCt)) $ - DB.execute - db - "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" - (currentTs, userId, toContactId) DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" @@ -1931,8 +2191,8 @@ updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {sh |] (BI showMessages, currentTs, userId, gId, gMemberId) -updateGroupMemberBlocked :: DB.Connection -> User -> GroupId -> GroupMemberId -> MemberRestrictionStatus -> IO () -updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do +updateGroupMemberBlocked :: DB.Connection -> User -> GroupInfo -> MemberRestrictionStatus -> GroupMember -> IO GroupMember +updateGroupMemberBlocked db User {userId} GroupInfo {groupId} mrs m@GroupMember {groupMemberId} = do currentTs <- getCurrentTime DB.execute db @@ -1941,7 +2201,8 @@ updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do SET member_restriction = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND group_member_id = ? |] - (memberBlocked, currentTs, userId, gId, gMemberId) + (mrs, currentTs, userId, groupId, groupMemberId) + pure m {blockedByAdmin = mrsBlocked mrs} getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do @@ -1973,36 +2234,6 @@ getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do Just groupConnReq -> Just (hostConnId, XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq}) _ -> Nothing -getXGrpMemIntroContGroup :: DB.Connection -> User -> GroupMember -> IO (Maybe (Int64, ConnReqInvitation)) -getXGrpMemIntroContGroup db User {userId} GroupMember {groupMemberId} = do - fmap join . maybeFirstRow toCont $ - DB.query - db - [sql| - SELECT ch.connection_id, c.conn_req_inv - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 - |] - (userId, userId, groupMemberId, GCHostMember) - where - toCont :: (Int64, Maybe ConnReqInvitation) -> Maybe (Int64, ConnReqInvitation) - toCont (hostConnId, connReq_) = case connReq_ of - Just connReq -> Just (hostConnId, connReq) - _ -> Nothing - getHostConnId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostConnId db user@User {userId} groupId = do hostMemberId <- getHostMemberId_ db user groupId @@ -2285,8 +2516,8 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g where VersionRange minV maxV = vr -updateUnknownMemberAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do +updateUnknownMemberAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} status = do _ <- updateMemberProfile db user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ @@ -2303,7 +2534,7 @@ updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMemb updated_at = ? WHERE user_id = ? AND group_member_id = ? |] - ( (memberRole, GCPostMember, GSMemAnnounced, groupMemberId' invitingMember) + ( (memberRole, GCPostMember, status, groupMemberId' invitingMember) :. (minV, maxV, currentTs, userId, groupMemberId) ) getGroupMemberById db vr user groupMemberId diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index a828a30925..a9bd03ed0e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -10,6 +11,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} @@ -21,6 +23,7 @@ module Simplex.Chat.Store.Messages -- * Message and chat item functions deleteContactCIs, getGroupFileInfo, + getGroupMemberFileInfo, deleteGroupChatItemsMessages, createNewSndMessage, createSndMsgDelivery, @@ -32,7 +35,9 @@ module Simplex.Chat.Store.Messages getPendingGroupMessages, deletePendingGroupMessage, deleteOldMessages, - updateChatTs, + MemberAttention (..), + updateChatTsStats, + setSupportChatTs, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -40,6 +45,7 @@ module Simplex.Chat.Store.Messages getChatPreviews, getDirectChat, getGroupChat, + getGroupChatScopeInfoForItem, getLocalChat, getDirectChatItemLast, getAllChatItems, @@ -53,13 +59,18 @@ module Simplex.Chat.Store.Messages markDirectChatItemDeleted, updateGroupChatItemStatus, updateGroupChatItem, + createGroupCIMentions, + updateGroupCIMentions, deleteGroupChatItem, updateGroupChatItemModerated, + updateMemberCIsModerated, updateGroupCIBlockedByAdmin, markGroupChatItemDeleted, + markMemberCIsDeleted, markGroupChatItemBlocked, markGroupCIBlockedByAdmin, markMessageReportsDeleted, + markReceivedGroupReportsDeleted, deleteLocalChatItem, updateDirectChatItemsRead, getDirectUnreadTimedItems, @@ -69,6 +80,7 @@ module Simplex.Chat.Store.Messages updateGroupChatItemsRead, getGroupUnreadTimedItems, updateGroupChatItemsReadList, + updateGroupScopeUnreadStats, setGroupChatItemsDeleteAt, updateLocalChatItemsRead, getChatRefViaItemId, @@ -125,6 +137,7 @@ module Simplex.Chat.Store.Messages ) where +import qualified Control.Exception as E import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class @@ -133,10 +146,12 @@ import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import Data.Either (fromRight, rights) import Data.Int (Int64) -import Data.List (sortBy) +import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L -import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Ord (Down (..), comparing) import Data.Text (Text) import qualified Data.Text as T @@ -152,7 +167,8 @@ import Simplex.Chat.Store.Groups import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Shared import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) +import Simplex.Chat.Types.Shared +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 @@ -186,6 +202,11 @@ getGroupFileInfo db User {userId} GroupInfo {groupId} = map toFileInfo <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ?") (userId, groupId) +getGroupMemberFileInfo :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO [CIFileInfo] +getGroupMemberFileInfo db User {userId} GroupInfo {groupId} GroupMember {groupMemberId} = + map toFileInfo + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ? AND i.group_member_id = ?") (userId, groupId, groupMemberId) + deleteGroupChatItemsMessages :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupChatItemsMessages db User {userId} GroupInfo {groupId} = do DB.execute db "DELETE FROM messages WHERE group_id = ?" (Only groupId) @@ -346,28 +367,136 @@ deleteOldMessages db createdAtCutoff = do type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId) -updateChatTs :: DB.Connection -> User -> ChatDirection c d -> UTCTime -> IO () -updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirection of - DirectChat Contact {contactId} -> +-- For support chats with members we track unanswered count - number of messages from the member +-- that weren't followed up by a message from any of moderators. +data MemberAttention + -- Message was received from member, increase unanswered counter and set support_chat_last_msg_from_member_ts. + -- `MAInc 0 Nothing` is used in two cases: + -- - when message from moderator is older than the last message from member (support_chat_last_msg_from_member_ts); + -- - for user's chat with moderators, where unanswered count is not tracked. + = MAInc Int (Maybe UTCTime) + -- Message was received from moderator, reset unanswered counter. + | MAReset + deriving (Show) + +updateChatTsStats :: DB.Connection -> VersionRangeChat -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO (ChatInfo c) +updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of + DirectChat ct@Contact {contactId} -> do DB.execute db "UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ?" (chatTs, userId, contactId) - GroupChat GroupInfo {groupId} -> + pure $ DirectChat ct {chatTs = Just chatTs} + GroupChat g@GroupInfo {groupId} Nothing -> do DB.execute db "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" (chatTs, userId, groupId) - LocalChat NoteFolder {noteFolderId} -> + pure $ GroupChat g {chatTs = Just chatTs} Nothing + GroupChat g@GroupInfo {groupId, membership, membersRequireAttention} (Just GCSIMemberSupport {groupMember_}) -> + case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + DB.execute + db + "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" + (chatTs, userId, groupId) + pure $ GroupChat g {membership = membership', chatTs = Just chatTs} (Just $ GCSIMemberSupport Nothing) + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if + | nowRequires && not didRequire -> do + DB.execute + db + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + |] + (chatTs, userId, groupId) + pure $ GroupChat g {membersRequireAttention = membersRequireAttention + 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + | not nowRequires && didRequire -> do + DB.execute + db + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (chatTs, userId, groupId) + pure $ GroupChat g {membersRequireAttention = membersRequireAttention - 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + | otherwise -> do + DB.execute + db + "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" + (chatTs, userId, groupId) + pure $ GroupChat g {chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + where + updateGMStats m@GroupMember {groupMemberId} = do + case chatStats_ of + Nothing -> + DB.execute + db + "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" + (chatTs, groupMemberId) + Just (unread, MAInc unanswered Nothing, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, unanswered, mentions, groupMemberId) + Just (unread, MAInc unanswered (Just lastMsgFromMemberTs), mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ?, + support_chat_last_msg_from_member_ts = ? + WHERE group_member_id = ? + |] + (chatTs, unread, unanswered, mentions, lastMsgFromMemberTs, groupMemberId) + Just (unread, MAReset, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, mentions, groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + LocalChat nf@NoteFolder {noteFolderId} -> do DB.execute db "UPDATE note_folders SET chat_ts = ? WHERE user_id = ? AND note_folder_id = ?" (chatTs, userId, noteFolderId) - _ -> pure () + pure $ LocalChat nf {chatTs = chatTs} + cInfo -> pure cInfo + +setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () +setSupportChatTs db groupMemberId chatTs = + DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = - createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live createdAt Nothing createdAt + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -381,9 +510,9 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live itemTs forwardedByMember createdAt +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -395,49 +524,62 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw Just QuotedMsg {msgRef = MsgRef {msgId = sharedMsgId, sentAt, sent, memberId}, content} -> uncurry (sharedMsgId,Just sentAt,Just content,,) $ case chatDirection of CDDirectRcv _ -> (Just $ not sent, Nothing) - CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> + CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ _ -> (Just $ Just userMemberId == memberId, memberId) createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItemNoMsg db user chatDirection ciContent itemTs = - createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing Nothing False itemTs Nothing + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing Nothing False False itemTs Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live itemTs forwardedByMember createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt = do DB.execute db [sql| INSERT INTO chat_items ( -- user and IDs - user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, + user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, msgId_) :. idsRow :. itemRow :. quoteRow' :. forwardedFromRow) + ((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live), BI userMention) :. ciTimedRow timed quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) - idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) + idsRow :: (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId, Maybe NoteFolderId) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) - CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId, Nothing) - CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing, Nothing) + CDGroupRcv GroupInfo {groupId} _ GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId, Nothing) + CDGroupSnd GroupInfo {groupId} _ -> (Nothing, Just groupId, Nothing, Nothing) CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) + groupScope :: Maybe (Maybe GroupChatScopeInfo) + groupScope = case chatDirection of + CDGroupRcv _ scope _ -> Just scope + CDGroupSnd _ scope -> Just scope + _ -> Nothing + groupScopeRow :: (Maybe GroupChatScopeTag, Maybe GroupMemberId) + groupScopeRow = case groupScope of + Just (Just GCSIMemberSupport {groupMember_}) -> (Just GCSTMemberSupport_, groupMemberId' <$> groupMember_) + _ -> (Nothing, Nothing) + includeInHistory :: Bool + includeInHistory = case groupScope of + Just Nothing -> isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) + _ -> False forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) forwardedFromRow = case itemForwarded of Nothing -> @@ -460,7 +602,7 @@ getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirectio getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) - CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> + CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId @@ -504,7 +646,9 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences + p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -558,10 +702,17 @@ data ChatPreviewData (c :: ChatType) where data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreviewData c) -type ChatStatsRow = (Int, Int, ChatItemId, BoolInt) +type ChatStatsRow = (Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat} +toChatStats (unreadCount, minUnreadItemId, BI unreadChat) = + ChatStats {unreadCount, unreadMentions = 0, reportsCount = 0, minUnreadItemId, unreadChat} + +type GroupStatsRow = (Int, Int, Int, ChatItemId, BoolInt) + +toGroupStats :: GroupStatsRow -> ChatStats +toGroupStats (unreadCount, unreadMentions, reportsCount, minUnreadItemId, BI unreadChat) = + ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -583,7 +734,6 @@ findDirectChatPreviews_ db User {userId} pagination clq = LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -665,9 +815,9 @@ findGroupChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQ findGroupChatPreviews_ db User {userId} pagination clq = map toPreview <$> getPreviews where - toPreview :: (GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow -> AChatPreviewData + toPreview :: (GroupId, UTCTime, Maybe ChatItemId) :. GroupStatsRow -> AChatPreviewData toPreview ((groupId, ts, lastItemId_) :. statsRow) = - ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toChatStats statsRow) + ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toGroupStats statsRow) baseQuery = [sql| SELECT @@ -676,19 +826,20 @@ findGroupChatPreviews_ db User {userId} pagination clq = ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -752,7 +903,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = |] p = baseParams :. (userId, search, search, search, search) queryWithPagination q p - queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. GroupStatsRow] queryWithPagination query params = case pagination of PTLast count -> DB.query db (query <> " ORDER BY g.chat_ts DESC LIMIT ?") (params :. Only count) PTAfter ts count -> DB.query db (query <> " AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ?") (params :. (ts, count)) @@ -762,9 +913,9 @@ getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreview getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db vr user groupId lastItem <- case lastItemId_ of - Just lastItemId -> (: []) <$> getGroupChatItem db user groupId lastItemId + Just lastItemId -> (: []) <$> getGroupCIWithReactions db user groupInfo lastItemId Nothing -> pure [] - pure $ AChat SCTGroup (Chat (GroupChat groupInfo) lastItem stats) + pure $ AChat SCTGroup (Chat (GroupChat groupInfo Nothing) lastItem stats) findLocalChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findLocalChatPreviews_ db User {userId} pagination clq = @@ -786,7 +937,6 @@ findLocalChatPreviews_ db User {userId} pagination clq = LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -851,7 +1001,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = +toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -875,7 +1025,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = @@ -884,7 +1034,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Just (CIDeleted @'CTLocal deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -940,7 +1090,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 = ? @@ -956,7 +1106,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 @@ -1017,6 +1167,7 @@ safeToDirectItem currentTs itemId = \case { chatDir = CIDirectSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1198,32 +1349,101 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChat db vr user groupId contentFilter pagination search_ = do +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId + scopeInfo <- mapM (getCreateGroupChatScopeInfo db vr user g) scope_ case pagination of - CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g contentFilter count search emptyChatStats - CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g contentFilter afterId count search - CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g contentFilter beforeId count search - CPAround aroundId count -> getGroupChatAround_ db user g contentFilter aroundId count search + CPLast count -> (,Nothing) <$> getGroupChatLast_ db user g scopeInfo contentFilter count search emptyChatStats + CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g scopeInfo contentFilter afterId count search + CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g scopeInfo contentFilter beforeId count search + CPAround aroundId count -> getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search CPInitial count -> do unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" - getGroupChatInitial_ db user g contentFilter count + getGroupChatInitial_ db user g scopeInfo contentFilter count -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> IO (Chat 'CTGroup) -getGroupChatLast_ db user g contentFilter count search stats = do - ciIds <- getGroupChatItemIDs db user g contentFilter GRLast count search - ts <- getCurrentTime - cis <- mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) stats +getCreateGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo +getCreateGroupChatScopeInfo db vr user GroupInfo {membership} = \case + GCSMemberSupport Nothing -> do + when (isNothing $ supportChat membership) $ do + ts <- liftIO getCurrentTime + liftIO $ setSupportChatTs db (groupMemberId' membership) ts + pure $ GCSIMemberSupport {groupMember_ = Nothing} + GCSMemberSupport (Just gmId) -> do + m <- getGroupMemberById db vr user gmId + when (isNothing $ supportChat m) $ do + ts <- liftIO getCurrentTime + liftIO $ setSupportChatTs db gmId ts + pure GCSIMemberSupport {groupMember_ = Just m} + +getGroupChatScopeInfoForItem :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScopeInfo) +getGroupChatScopeInfoForItem db vr user g itemId = + getGroupChatScopeForItem_ db itemId >>= mapM (getGroupChatScopeInfo db vr user g) + +getGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo +getGroupChatScopeInfo db vr user GroupInfo {membership} = \case + GCSMemberSupport Nothing -> case supportChat membership of + Nothing -> throwError $ SEInternalError "no moderators support chat" + Just _supportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} + GCSMemberSupport (Just gmId) -> do + m <- getGroupMemberById db vr user gmId + case supportChat m of + Nothing -> throwError $ SEInternalError "no support chat" + Just _supportChat -> pure GCSIMemberSupport {groupMember_ = Just m} + +getGroupChatScopeForItem_ :: DB.Connection -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScope) +getGroupChatScopeForItem_ db itemId = + ExceptT . firstRow toScope (SEChatItemNotFound itemId) $ + DB.query + db + [sql| + SELECT group_scope_tag, group_scope_group_member_id + FROM chat_items + WHERE chat_item_id = ? + |] + (Only itemId) + where + toScope (scopeTag, scopeMemberId) = + case (scopeTag, scopeMemberId) of + (Just GCSTMemberSupport_, Just gmId) -> Just $ GCSMemberSupport gmId + (Just GCSTMemberSupport_, Nothing) -> Just $ GCSMemberSupport Nothing + (Nothing, Nothing) -> Nothing + (Nothing, Just _) -> Nothing -- shouldn't happen + +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do + ciIds <- getGroupChatItemIDs db user g scopeInfo_ contentFilter GRLast count search + ts <- liftIO getCurrentTime + cis <- mapM (liftIO . safeGetGroupItem db user g ts) ciIds + pure $ Chat (GroupChat g scopeInfo_) (reverse cis) stats data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId -getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> IO [ChatItemId] -getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range count search = case contentFilter of - Just mcTag -> idsQuery (baseCond <> " AND msg_content_tag = ? ") (userId, groupId, mcTag) - Nothing -> idsQuery baseCond (userId, groupId) +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> ExceptT StoreError IO [ChatItemId] +getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter range count search = case (scopeInfo_, contentFilter) of + (Nothing, Nothing) -> + liftIO $ + idsQuery + (baseCond <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL ") + (userId, groupId) + (Nothing, Just mcTag) -> + liftIO $ + idsQuery + (baseCond <> " AND msg_content_tag = ? ") + (userId, groupId, mcTag) + (Just GCSIMemberSupport {groupMember_ = Just m}, Nothing) -> + liftIO $ + idsQuery + (baseCond <> " AND group_scope_tag = ? AND group_scope_group_member_id = ? ") + (userId, groupId, GCSTMemberSupport_, groupMemberId' m) + (Just GCSIMemberSupport {groupMember_ = Nothing}, Nothing) -> + liftIO $ + idsQuery + (baseCond <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NULL ") + (userId, groupId, GCSTMemberSupport_) + (Just _scope, Just _mcTag) -> + throwError $ SEInternalError "group scope and content filter are not supported together" where baseQuery = " SELECT chat_item_id FROM chat_items WHERE " baseCond = " user_id = ? AND group_id = ? " @@ -1241,7 +1461,7 @@ getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range cou (orParams ts itemId) " ORDER BY item_ts DESC, chat_item_id DESC " where - orCond c1 c2 = " (" <> c <> " AND " <> c1 <> ") OR (" <> c <> " AND " <> c2 <> ") " + orCond c1 c2 = " ((" <> c <> " AND " <> c1 <> ") OR (" <> c <> " AND " <> c2 <> ")) " orParams ts itemId = (p :. (Only ts) :. p :. (ts, itemId)) rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] rangeQuery c p ob @@ -1272,6 +1492,7 @@ safeToGroupItem currentTs itemId = \case { chatDir = CIGroupSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1294,81 +1515,82 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatAfter_ db user g@GroupInfo {groupId} contentFilter afterId count search = do +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) - ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search + ciIds <- getGroupChatItemIDs db user g scopeInfo contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) cis emptyChatStats + pure $ Chat (GroupChat g scopeInfo) cis emptyChatStats -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatBefore_ db user g@GroupInfo {groupId} contentFilter beforeId count search = do +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) - ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search + ciIds <- getGroupChatItemIDs db user g scopeInfo contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) emptyChatStats + pure $ Chat (GroupChat g scopeInfo) (reverse cis) emptyChatStats -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround_ db user g contentFilter aroundId count search = do - stats <- liftIO $ getGroupStats_ db user g - getGroupChatAround' db user g contentFilter aroundId count search stats +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search = do + stats <- getGroupStats_ db user g scopeInfo + getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround' db user g@GroupInfo {groupId} contentFilter aroundId count search stats = do - aroundCI <- getGroupChatItem db user groupId aroundId +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats = do + aroundCI <- getGroupCIWithReactions db user g aroundId let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) afterRange = GRAfter (chatItemTs aroundCI) (cChatItemId aroundCI) - beforeIds <- liftIO $ getGroupChatItemIDs db user g contentFilter beforeRange count search - afterIds <- liftIO $ getGroupChatItemIDs db user g contentFilter afterRange count search + beforeIds <- getGroupChatItemIDs db user g scopeInfo contentFilter beforeRange count search + afterIds <- getGroupChatItemIDs db user g scopeInfo contentFilter afterRange count search ts <- liftIO getCurrentTime beforeCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) beforeIds afterCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) afterIds let cis = reverse beforeCIs <> [aroundCI] <> afterCIs navInfo <- liftIO $ getNavInfo cis - pure (Chat (GroupChat g) cis stats, Just navInfo) + pure (Chat (GroupChat g scopeInfo) cis stats, Just navInfo) where getNavInfo cis_ = case cis_ of [] -> pure $ NavigationInfo 0 0 cis -> getGroupNavInfo_ db user g (last cis) -getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatInitial_ db user g contentFilter count = do - liftIO (getGroupMinUnreadId_ db user g contentFilter) >>= \case +getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do + getGroupMinUnreadId_ db user g scopeInfo_ contentFilter >>= \case Just minUnreadItemId -> do - stats <- liftIO $ getStats minUnreadItemId =<< getGroupUnreadCount_ db user g Nothing - getGroupChatAround' db user g contentFilter minUnreadItemId count "" stats - Nothing -> liftIO $ do - stats <- getStats 0 0 - (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" stats + unreadCounts <- getGroupUnreadCount_ db user g scopeInfo_ Nothing + stats <- liftIO $ getStats minUnreadItemId unreadCounts + getGroupChatAround' db user g scopeInfo_ contentFilter minUnreadItemId count "" stats + Nothing -> do + stats <- liftIO $ getStats 0 (0, 0) + (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats where - getStats minUnreadItemId unreadCount = do + getStats minUnreadItemId (unreadCount, unreadMentions) = do reportsCount <- getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} -getGroupStats_ :: DB.Connection -> User -> GroupInfo -> IO ChatStats -getGroupStats_ db user g = do - minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g Nothing - unreadCount <- getGroupUnreadCount_ db user g Nothing - reportsCount <- getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} +getGroupStats_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> ExceptT StoreError IO ChatStats +getGroupStats_ db user g scopeInfo_ = do + minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g scopeInfo_ Nothing + (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g scopeInfo_ Nothing + reportsCount <- liftIO $ getGroupReportsCount_ db user g False + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} -getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Maybe ChatItemId) -getGroupMinUnreadId_ db user g contentFilter = +getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) +getGroupMinUnreadId_ db user g scopeInfo_ contentFilter = fmap join . maybeFirstRow fromOnly $ - queryUnreadGroupItems db user g contentFilter baseQuery orderLimit + queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery orderLimit where baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" -getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO Int -getGroupUnreadCount_ db user g contentFilter = - fromOnly . head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" +getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Int, Int) +getGroupUnreadCount_ db user g scopeInfo_ contentFilter = + head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery "" where - baseQuery = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? " + baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL " getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = @@ -1378,19 +1600,35 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" (userId, groupId, MCReport_, BI archived) -queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Query -> Query -> IO [r] -queryUnreadGroupItems db User {userId} GroupInfo {groupId} contentFilter baseQuery orderLimit = - case contentFilter of - Just mcTag -> - DB.query - db - (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) - (userId, groupId, mcTag, CISRcvNew) - Nothing -> - DB.query - db - (baseQuery <> " AND item_status = ? " <> orderLimit) - (userId, groupId, CISRcvNew) +queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> ExceptT StoreError IO [r] +queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter baseQuery orderLimit = + case (scopeInfo_, contentFilter) of + (Nothing, Nothing) -> + liftIO $ + DB.query + db + (baseQuery <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? " <> orderLimit) + (userId, groupId, CISRcvNew) + (Nothing, Just mcTag) -> + liftIO $ + DB.query + db + (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) + (userId, groupId, mcTag, CISRcvNew) + (Just GCSIMemberSupport {groupMember_ = Just m}, Nothing) -> + liftIO $ + DB.query + db + (baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id = ? AND item_status = ? " <> orderLimit) + (userId, groupId, GCSTMemberSupport_, groupMemberId' m, CISRcvNew) + (Just GCSIMemberSupport {groupMember_ = Nothing}, Nothing) -> + liftIO $ + DB.query + db + (baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NULL AND item_status = ? " <> orderLimit) + (userId, groupId, GCSTMemberSupport_, CISRcvNew) + (Just _scope, Just _mcTag) -> + throwError $ SEInternalError "group scope and content filter are not supported together" getGroupNavInfo_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO NavigationInfo getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do @@ -1497,6 +1735,7 @@ safeToLocalItem currentTs itemId = \case { chatDir = CILocalSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1662,12 +1901,22 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do :. (userId, noteFolderId, ciCreatedAt afterCI, cChatItemId afterCI) ) -toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) +toChatItemRef :: + (ChatItemId, Maybe ContactId, Maybe GroupId, Maybe GroupChatScopeTag, Maybe GroupMemberId, Maybe NoteFolderId) -> + Either StoreError (ChatRef, ChatItemId) toChatItemRef = \case - (itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId, itemId) - (itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId, itemId) - (itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId, itemId) - (itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing + (itemId, Just contactId, Nothing, Nothing, Nothing, Nothing) -> + Right (ChatRef CTDirect contactId Nothing, itemId) + (itemId, Nothing, Just groupId, Nothing, Nothing, Nothing) -> + Right (ChatRef CTGroup groupId Nothing, itemId) + (itemId, Nothing, Just groupId, Just GCSTMemberSupport_, Nothing, Nothing) -> + Right (ChatRef CTGroup groupId (Just (GCSMemberSupport Nothing)), itemId) + (itemId, Nothing, Just groupId, Just GCSTMemberSupport_, Just scopeGMId, Nothing) -> + Right (ChatRef CTGroup groupId (Just (GCSMemberSupport $ Just scopeGMId)), itemId) + (itemId, Nothing, Nothing, Nothing, Nothing, Just folderId) -> + Right (ChatRef CTLocal folderId Nothing, itemId) + (itemId, _, _, _, _, _) -> + Left $ SEBadChatItem itemId Nothing updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> IO () updateDirectChatItemsRead db User {userId} contactId = do @@ -1734,8 +1983,8 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i (deleteAt, userId, contactId, chatItemId) pure (chatItemId, deleteAt) -updateGroupChatItemsRead :: DB.Connection -> User -> GroupId -> IO () -updateGroupChatItemsRead db User {userId} groupId = do +updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> IO () +updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope = do currentTs <- getCurrentTime DB.execute db @@ -1744,6 +1993,20 @@ updateGroupChatItemsRead db User {userId} groupId = do WHERE user_id = ? AND group_id = ? AND item_status = ? |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) + case scope of + Nothing -> pure () + Just GCSMemberSupport {groupMemberId_} -> do + let gmId = fromMaybe (groupMemberId' membership) groupMemberId_ + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0 + WHERE group_member_id = ? + |] + (Only gmId) getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> IO [(ChatItemId, Int)] getGroupUnreadTimedItems db User {userId} groupId = @@ -1756,33 +2019,83 @@ getGroupUnreadTimedItems db User {userId} groupId = |] (userId, groupId, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> User -> GroupId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] -updateGroupChatItemsReadList db User {userId} groupId itemIds = do - currentTs <- getCurrentTime - catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds +updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) +updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do + currentTs <- liftIO getCurrentTime + -- Possible improvement is to differentiate retrieval queries for each scope, + -- but we rely on UI to not pass item IDs from incorrect scope. + readItemsData <- liftIO $ catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds + g' <- case scopeInfo_ of + Nothing -> pure g + Just scopeInfo@GCSIMemberSupport {groupMember_} -> do + let decStats = countReadItems groupMember_ readItemsData + liftIO $ updateGroupScopeUnreadStats db vr user g scopeInfo decStats + pure (timedItems readItemsData, g') where - getUpdateGroupItem currentTs itemId = do - ttl_ <- maybeFirstRow fromOnly getUnreadTimedItem - setItemRead - pure $ (itemId,) <$> ttl_ + getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) + getUpdateGroupItem currentTs itemId = + maybeFirstRow id $ + DB.query + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? + RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention + |] + (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) + countReadItems :: Maybe GroupMember -> [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> (Int, Int, Int) + countReadItems scopeMember_ readItemsData = + let unread = length readItemsData + (unanswered, mentions) = foldl' countItem (0, 0) readItemsData + in (unread, unanswered, mentions) where - getUnreadTimedItem = - DB.query - db - [sql| - SELECT timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - |] - (userId, groupId, CISRcvNew, itemId) - setItemRead = - DB.execute - db - [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? - |] - (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) + countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) + countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = + let unanswered' = case (scopeMember_, itemGMId_) of + (Just scopeMember, Just itemGMId) | itemGMId == groupMemberId' scopeMember -> unanswered + 1 + _ -> unanswered + mentions' = case userMention_ of + Just (BI True) -> mentions + 1 + _ -> mentions + in (unanswered', mentions') + timedItems :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> [(ChatItemId, Int)] + timedItems = foldl' addTimedItem [] + where + addTimedItem acc (itemId, Just ttl, Nothing, _, _) = (itemId, ttl) : acc + addTimedItem acc _ = acc + +updateGroupScopeUnreadStats :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO GroupInfo +updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unread, unanswered, mentions) = + case scopeInfo of + GCSIMemberSupport {groupMember_} -> case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + pure g {membership = membership'} + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if (not nowRequires && didRequire) + then decreaseGroupMembersRequireAttention db user g + else pure g + where + updateGMStats m@GroupMember {groupMemberId} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ?, + updated_at = ? + WHERE group_member_id = ? + |] + (unread, unanswered, mentions, currentTs, groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + +deriving instance Show BoolInt setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do @@ -1806,7 +2119,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) -type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) @@ -1830,7 +2143,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1854,7 +2167,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -1863,7 +2176,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1886,48 +2199,69 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing -- this function can be changed so it never fails, not only avoid failure on invalid json -toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do - chatItem $ fromRight invalid $ dbParseACIContent itemContentText - where - member_ = toMaybeGroupMember userContactId memberRow_ - quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - deletedByGroupMember_ = toMaybeGroupMember userContactId deletedByGroupMemberRow_ - invalid = ACIContent msgDir $ CIInvalidJSON itemContentText - chatItem itemContent = case (itemContent, itemStatus, member_, fileStatus_) of - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> - Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent (maybeCIFile fileStatus) - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> - Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent Nothing - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> - Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent (maybeCIFile fileStatus) - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> - Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent Nothing - _ -> badItem - maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) - maybeCIFile fileStatus = - case (fileId_, fileName_, fileSize_, fileProtocol_) of - (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> - let cfArgs = CFArgs <$> fileKey <*> fileNonce - fileSource = (`CryptoFile` cfArgs) <$> filePath - in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} - _ -> Nothing - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup - cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} - badItem = Left $ SEBadChatItem itemId (Just itemTs) - ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d - ciMeta content status = - let itemDeleted' = case itemDeleted of - DBCINotDeleted -> Nothing - DBCIBlocked -> Just (CIBlocked deletedTs) - DBCIBlockedByAdmin -> Just (CIBlockedByAdmin deletedTs) - _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) - itemEdited' = maybe False unBI itemEdited - itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs forwardedByMember createdAt updatedAt - ciTimed :: Maybe CITimed - ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} +toGroupChatItem :: + UTCTime -> + Int64 -> + ChatItemRow + :. Only (Maybe GroupMemberId) + :. MaybeGroupMemberRow + :. GroupQuoteRow + :. MaybeGroupMemberRow -> + Either StoreError (CChatItem 'CTGroup) +toGroupChatItem + currentTs + userContactId + ( ( (itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) + :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) + :. forwardedFromRow + :. (timedTTL, timedDeleteAt, itemLive, BI userMention) + :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_) + ) + :. Only forwardedByMember + :. memberRow_ + :. (quoteRow :. quotedMemberRow_) + :. deletedByGroupMemberRow_ + ) = do + chatItem $ fromRight invalid $ dbParseACIContent itemContentText + where + member_ = toMaybeGroupMember userContactId memberRow_ + quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ + deletedByGroupMember_ = toMaybeGroupMember userContactId deletedByGroupMemberRow_ + invalid = ACIContent msgDir $ CIInvalidJSON itemContentText + chatItem itemContent = case (itemContent, itemStatus, member_, fileStatus_) of + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent Nothing + _ -> badItem + maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) + maybeCIFile fileStatus = + case (fileId_, fileName_, fileSize_, fileProtocol_) of + (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> + let cfArgs = CFArgs <$> fileKey <*> fileNonce + fileSource = (`CryptoFile` cfArgs) <$> filePath + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} + _ -> Nothing + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup + cItem d chatDir ciStatus content file = + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + badItem = Left $ SEBadChatItem itemId (Just itemTs) + ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d + ciMeta content status = + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + DBCIBlocked -> Just (CIBlocked deletedTs) + DBCIBlockedByAdmin -> Just (CIBlockedByAdmin deletedTs) + _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) + itemEdited' = maybe False unBI itemEdited + itemForwarded = toCIForwardedFrom forwardedFromRow + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs forwardedByMember createdAt updatedAt + ciTimed :: Maybe CITimed + ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do @@ -1953,7 +2287,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC @@ -1964,7 +2298,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) @@ -1977,7 +2311,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) @@ -1989,7 +2323,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE chat_item_id = ? |] @@ -2129,18 +2463,18 @@ deleteDirectChatItem db User {userId} Contact {contactId} ci = do (userId, contactId, itemId) deleteChatItemMessages_ :: DB.Connection -> ChatItemId -> IO () -deleteChatItemMessages_ db itemId = - DB.execute - db - [sql| - DELETE FROM messages - WHERE message_id IN ( - SELECT message_id - FROM chat_item_messages - WHERE chat_item_id = ? - ) - |] - (Only itemId) +deleteChatItemMessages_ db itemId = DB.execute db deleteChatItemMessagesQuery (Only itemId) + +deleteChatItemMessagesQuery :: Query +deleteChatItemMessagesQuery = + [sql| + DELETE FROM messages + WHERE message_id IN ( + SELECT message_id + FROM chat_item_messages + WHERE chat_item_id = ? + ) + |] deleteChatItemVersions_ :: DB.Connection -> ChatItemId -> IO () deleteChatItemVersions_ db itemId = @@ -2198,7 +2532,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -2250,12 +2584,14 @@ getGroupCIWithReactions db user g@GroupInfo {groupId} itemId = do liftIO . groupCIWithReactions db g =<< getGroupChatItem db user groupId itemId groupCIWithReactions :: DB.Connection -> GroupInfo -> CChatItem 'CTGroup -> IO (CChatItem 'CTGroup) -groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of - Just sharedMsgId -> do - let GroupMember {memberId} = chatItemMember g ci - reactions <- getGroupCIReactions db g memberId sharedMsgId - pure $ CChatItem md ci {reactions} - Nothing -> pure cci +groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemId, itemSharedMsgId}}) = do + mentions <- getGroupCIMentions db itemId + case itemSharedMsgId of + Just sharedMsgId -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- getGroupCIReactions db g memberId sharedMsgId + pure $ CChatItem md ci {reactions, mentions} + Nothing -> pure $ if null mentions then cci else CChatItem md ci {mentions} updateGroupChatItem :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) updateGroupChatItem db user groupId ci newContent edited live msgId_ = do @@ -2281,6 +2617,28 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt +createGroupCIMentions :: forall d. DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName CIMention -> IO (ChatItem 'CTGroup d) +createGroupCIMentions db GroupInfo {groupId} ci mentions = do + DB.executeMany db "INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?)" rows + pure (ci :: ChatItem 'CTGroup d) {mentions} + where + rows = map (\(name, CIMention {memberId}) -> (ciId, groupId, memberId, name)) $ M.assocs mentions + ciId = chatItemId' ci + +updateGroupCIMentions :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName CIMention -> IO (ChatItem 'CTGroup d) +updateGroupCIMentions db g ci@ChatItem {mentions} mentions' + | mentions' == mentions = pure ci + | otherwise = do + unless (null mentions) $ deleteMentions + if null mentions' + then pure ci + else -- This is a fallback for the error that should not happen in practice. + -- In theory, it may happen in item mentions in database are different from item record. + createMentions `E.catch` \e -> if constraintError e then deleteMentions >> createMentions else E.throwIO e + where + deleteMentions = DB.execute db "DELETE FROM chat_item_mentions WHERE chat_item_id = ?" (Only $ chatItemId' ci) + createMentions = createGroupCIMentions db g ci mentions' + deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do let itemId = chatItemId' ci @@ -2314,6 +2672,34 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing} +updateMemberCIsModerated :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> UTCTime -> IO () +updateMemberCIsModerated db User {userId} GroupInfo {groupId, membership} member byGroupMember md deletedTs = do + itemIds <- updateCIs =<< getCurrentTime + DB.executeMany db deleteChatItemMessagesQuery itemIds + DB.executeMany db "DELETE FROM chat_item_versions WHERE chat_item_id = ?" itemIds + where + memId = groupMemberId' member + updateQuery = + [sql| + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + |] + updateCIs :: UTCTime -> IO [Only Int64] + updateCIs currentTs + | memId == groupMemberId' membership = + DB.query + db + (updateQuery <> " AND group_member_id IS NULL AND item_sent = 1 RETURNING chat_item_id") + (columns :. (userId, groupId)) + | otherwise = + DB.query + db + (updateQuery <> " AND group_member_id = ? RETURNING chat_item_id") + (columns :. (userId, groupId, memId)) + where + columns = (deletedTs, groupMemberId' byGroupMember, msgDirToModeratedContent_ md, ciModeratedText, currentTs) + updateGroupCIBlockedByAdmin :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> UTCTime -> IO (ChatItem 'CTGroup d) updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = do currentTs <- getCurrentTime @@ -2360,6 +2746,31 @@ markGroupChatItemDeleted db User {userId} GroupInfo {groupId} ci@ChatItem {meta} (DBCIDeleted, deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) pure ci {meta = meta {itemDeleted, editable = False, deletable = False}} +markMemberCIsDeleted :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> UTCTime -> IO () +markMemberCIsDeleted db User {userId} GroupInfo {groupId, membership} member byGroupMember deletedTs = + updateCIs =<< getCurrentTime + where + memId = groupMemberId' member + updateQuery = + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + |] + updateCIs currentTs + | memId == groupMemberId' membership = + DB.execute + db + (updateQuery <> " AND group_member_id IS NULL AND item_sent = 1") + (columns :. (userId, groupId)) + | otherwise = + DB.execute + db + (updateQuery <> " AND group_member_id = ?") + (columns :. (userId, groupId, memId)) + where + columns = (DBCIDeleted, deletedTs, groupMemberId' byGroupMember, currentTs) + markGroupChatItemBlocked :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv) markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do deletedTs <- getCurrentTime @@ -2395,13 +2806,27 @@ markMessageReportsDeleted db User {userId} GroupInfo {groupId} ChatItem {meta = [sql| UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? AND item_deleted = ? RETURNING chat_item_id; |] - (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId) + (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId, DBCINotDeleted) -getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do +markReceivedGroupReportsDeleted :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO [ChatItemId] +markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} deletedTs = do + currentTs <- liftIO getCurrentTime + map fromOnly + <$> DB.query + db + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + RETURNING chat_item_id + |] + (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted) + +getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId sharedMsgId = do itemId <- ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query @@ -2414,10 +2839,10 @@ getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId shared LIMIT 1 |] (userId, groupId, groupMemberId, sharedMsgId) - getGroupChatItem db user groupId itemId + getGroupCIWithReactions db user g itemId -getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupId -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId = do +getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupInfo -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupMemberCIBySharedMsgId db user@User {userId} g@GroupInfo {groupId} memberId sharedMsgId = do itemId <- ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query @@ -2433,7 +2858,7 @@ getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId LIMIT 1 |] (GCUserMember, userId, groupId, memberId, sharedMsgId) - getGroupChatItem db user groupId itemId + getGroupCIWithReactions db user g itemId getGroupChatItemsByAgentMsgId :: DB.Connection -> User -> GroupId -> Int64 -> AgentMsgId -> IO [CChatItem 'CTGroup] getGroupChatItemsByAgentMsgId db user groupId connId msgId = do @@ -2454,7 +2879,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember @@ -2463,18 +2888,26 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, + rm.created_at, rm.updated_at, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, - dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences + dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, + dbm.created_at, dbm.updated_at, + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id + LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id @@ -2523,8 +2956,7 @@ getGroupChatItemIdByText db User {userId, localDisplayName = userName} groupId c SELECT i.chat_item_id FROM chat_items i JOIN group_members m ON m.group_member_id = i.group_member_id - JOIN contacts c ON c.contact_id = m.contact_id - WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + WHERE i.user_id = ? AND i.group_id = ? AND m.local_display_name = ? AND i.item_text like ? ORDER BY i.chat_item_id DESC LIMIT 1 |] @@ -2558,7 +2990,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -2637,7 +3069,7 @@ getChatItemByFileId db vr user@User {userId} fileId = do DB.query db [sql| - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i JOIN files f ON f.chat_item_id = i.chat_item_id WHERE f.user_id = ? AND f.file_id = ? @@ -2659,7 +3091,7 @@ getChatItemByGroupId db vr user@User {userId} groupId = do DB.query db [sql| - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i JOIN groups g ON g.chat_item_id = i.chat_item_id WHERE g.user_id = ? AND g.group_id = ? @@ -2674,24 +3106,26 @@ getChatRefViaItemId db User {userId} itemId = do DB.query db "SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId) where toChatRef = \case - (Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId - (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId + (Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId Nothing + -- Only used in CLI and unused APIs + (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId Nothing (_, _) -> Left $ SEBadChatItem itemId Nothing getAChatItem :: DB.Connection -> VersionRangeChat -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem -getAChatItem db vr user chatRef itemId = do - aci <- case chatRef of - ChatRef CTDirect contactId -> do - ct <- getContact db vr user contactId - (CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId +getAChatItem db vr user (ChatRef cType chatId scope) itemId = do + aci <- case cType of + CTDirect -> do + ct <- getContact db vr user chatId + (CChatItem msgDir ci) <- getDirectChatItem db user chatId itemId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - ChatRef CTGroup groupId -> do - gInfo <- getGroupInfo db vr user groupId - (CChatItem msgDir ci) <- getGroupChatItem db user groupId itemId - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci - ChatRef CTLocal folderId -> do - nf <- getNoteFolder db user folderId - CChatItem msgDir ci <- getLocalChatItem db user folderId itemId + CTGroup -> do + gInfo <- getGroupInfo db vr user chatId + (CChatItem msgDir ci) <- getGroupChatItem db user chatId itemId + scopeInfo <- mapM (getGroupChatScopeInfo db vr user gInfo) scope + pure $ AChatItem SCTGroup msgDir (GroupChat gInfo scopeInfo) ci + CTLocal -> do + nf <- getNoteFolder db user chatId + CChatItem msgDir ci <- getLocalChatItem db user chatId itemId pure $ AChatItem SCTLocal msgDir (LocalChat nf) ci _ -> throwError $ SEChatItemNotFound itemId liftIO $ getACIReactions db aci @@ -2701,9 +3135,9 @@ getAChatItemBySharedMsgId db user cd sharedMsgId = case cd of CDDirectRcv ct@Contact {contactId} -> do (CChatItem msgDir ci) <- getDirectChatItemBySharedMsgId db user contactId sharedMsgId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - CDGroupRcv g@GroupInfo {groupId} GroupMember {groupMemberId} -> do - (CChatItem msgDir ci) <- getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId - pure $ AChatItem SCTGroup msgDir (GroupChat g) ci + CDGroupRcv g scopeInfo GroupMember {groupMemberId} -> do + (CChatItem msgDir ci) <- getGroupChatItemBySharedMsgId db user g groupMemberId sharedMsgId + pure $ AChatItem SCTGroup msgDir (GroupChat g scopeInfo) ci getChatItemVersions :: DB.Connection -> ChatItemId -> IO [ChatItemVersion] getChatItemVersions db itemId = do @@ -2756,13 +3190,35 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = |] (groupId, itemMemberId, itemSharedMsgId) +getGroupCIMentions :: DB.Connection -> ChatItemId -> IO (Map MemberName CIMention) +getGroupCIMentions db ciId = + M.fromList . map mentionedMember + <$> DB.query + db + [sql| + SELECT r.display_name, r.member_id, m.group_member_id, m.member_role, p.display_name, p.local_alias + FROM chat_item_mentions r + LEFT JOIN group_members m ON r.group_id = m.group_id AND r.member_id = m.member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE r.chat_item_id = ? + |] + (Only ciId) + where + mentionedMember :: (ContactName, MemberId, Maybe GroupMemberId, Maybe GroupMemberRole, Maybe Text, Maybe Text) -> (ContactName, CIMention) + mentionedMember (name, memberId, gmId_, mRole_, displayName_, localAlias) = + let memberRef = case (gmId_, mRole_, displayName_) of + (Just groupMemberId, Just memberRole, Just displayName) -> + Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} + _ -> Nothing + in (name, CIMention {memberId, memberRef}) + getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of Just itemSharedMId -> case chat of DirectChat ct -> do reactions <- getDirectCIReactions db ct itemSharedMId pure $ AChatItem SCTDirect md chat ci {reactions} - GroupChat g -> do + GroupChat g _s -> do let GroupMember {memberId} = chatItemMember g ci reactions <- getGroupCIReactions db g memberId itemSharedMId pure $ AChatItem SCTGroup md chat ci {reactions} @@ -2874,16 +3330,23 @@ getTimedItems db User {userId} startTimedThreadCutoff = <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, timed_delete_at + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, timed_delete_at FROM chat_items WHERE user_id = ? AND timed_delete_at IS NOT NULL AND timed_delete_at <= ? |] (userId, startTimedThreadCutoff) where - toCIRefDeleteAt :: (ChatItemId, Maybe ContactId, Maybe GroupId, UTCTime) -> Maybe ((ChatRef, ChatItemId), UTCTime) + toCIRefDeleteAt :: (ChatItemId, Maybe ContactId, Maybe GroupId, Maybe GroupChatScopeTag, Maybe GroupMemberId, UTCTime) -> Maybe ((ChatRef, ChatItemId), UTCTime) toCIRefDeleteAt = \case - (itemId, Just contactId, Nothing, deleteAt) -> Just ((ChatRef CTDirect contactId, itemId), deleteAt) - (itemId, Nothing, Just groupId, deleteAt) -> Just ((ChatRef CTGroup groupId, itemId), deleteAt) + (itemId, Just contactId, Nothing, Nothing, Nothing, deleteAt) -> + Just ((ChatRef CTDirect contactId Nothing, itemId), deleteAt) + (itemId, Nothing, Just groupId, scopeTag_, scopeGMId_, deleteAt) -> + let scope = case (scopeTag_, scopeGMId_) of + (Nothing, Nothing) -> Nothing + (Just GCSTMemberSupport_, Just groupMemberId) -> Just $ GCSMemberSupport (Just groupMemberId) + (Just GCSTMemberSupport_, Nothing) -> Just $ GCSMemberSupport Nothing + (Nothing, Just _) -> Nothing -- should not happen + in Just ((ChatRef CTGroup groupId scope, itemId), deleteAt) _ -> Nothing getChatItemTTL :: DB.Connection -> User -> IO Int64 @@ -3056,10 +3519,9 @@ getGroupSndStatusCounts db itemId = (Only itemId) getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> GroupMember -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] -getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do +getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do ciIds <- getLastItemIds_ - -- use getGroupCIWithReactions to read reactions data - reverse <$> mapM (runExceptT . getGroupChatItem db user groupId) ciIds + reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds where getLastItemIds_ :: IO [ChatItemId] getLastItemIds_ = @@ -3070,12 +3532,11 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do SELECT i.chat_item_id FROM chat_items i LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? - WHERE i.user_id = ? AND i.group_id = ? - AND i.item_content_tag IN (?,?) - AND i.msg_content_tag NOT IN (?) + WHERE s.group_snd_item_status_id IS NULL + AND i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 AND i.item_deleted = 0 - AND s.group_snd_item_status_id IS NULL ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] - (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count) + (groupMemberId' m, userId, groupId, count) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 285a952279..2e9ff571e9 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -5,11 +5,17 @@ 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.Chat.Store.Postgres.Migrations.M20250512_member_admission +import Simplex.Chat.Store.Postgres.Migrations.M20250513_group_scope 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), + ("20250512_member_admission", m20250512_member_admission, Just down_m20250512_member_admission), + ("20250513_group_scope", m20250513_group_scope, Just down_m20250513_group_scope) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index e8fd77aa0d..08ce2d81bc 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -128,7 +128,6 @@ CREATE TABLE groups( updated_at TIMESTAMPTZ NOT NULL, chat_item_id BIGINT DEFAULT NULL, enable_ntfs SMALLINT, - host_conn_custom_user_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, unread_chat SMALLINT NOT NULL DEFAULT 0, chat_ts TIMESTAMPTZ, favorite SMALLINT NOT NULL DEFAULT 0, @@ -425,7 +424,9 @@ CREATE TABLE chat_items( fwd_from_group_id BIGINT REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL, via_proxy SMALLINT, - msg_content_tag TEXT + msg_content_tag TEXT, + include_in_history SMALLINT NOT NULL DEFAULT 0, + user_mention SMALLINT NOT NULL DEFAULT 0 ); ALTER TABLE groups ADD CONSTRAINT fk_groups_chat_items @@ -675,6 +676,13 @@ CREATE TABLE chat_tags_chats( group_id BIGINT REFERENCES groups ON DELETE CASCADE, chat_tag_id BIGINT NOT NULL REFERENCES chat_tags ON DELETE CASCADE ); +CREATE TABLE chat_item_mentions ( + chat_item_mention_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BYTEA NOT NULL, + display_name TEXT NOT NULL +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -683,7 +691,6 @@ CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); -CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( user_id, contact_id, @@ -756,9 +763,6 @@ CREATE INDEX idx_group_members_contact_profile_id ON group_members( CREATE INDEX idx_group_members_user_id ON group_members(user_id); CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); -CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( - host_conn_custom_user_profile_id -); CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); CREATE INDEX idx_messages_group_id ON messages(group_id); @@ -1012,4 +1016,40 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( item_deleted, item_sent ); +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses( + chat_item_id, + group_member_id +); +CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions( + chat_item_id +); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions( + chat_item_id, + display_name +); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions( + chat_item_id, + member_id +); +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( + user_id, + group_id, + item_status, + user_mention +); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( + group_id, + shared_msg_id +); |] 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..4b3b7e9640 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs @@ -0,0 +1,25 @@ +{-# 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; +ALTER TABLE connections ADD COLUMN via_short_link_contact 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; +ALTER TABLE connections DROP COLUMN via_short_link_contact; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250512_member_admission.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250512_member_admission.hs new file mode 100644 index 0000000000..eb0d73a523 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250512_member_admission.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250512_member_admission where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250512_member_admission :: Text +m20250512_member_admission = + T.pack + [r| +ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; +|] + +down_m20250512_member_admission :: Text +down_m20250512_member_admission = + T.pack + [r| +ALTER TABLE group_profiles DROP COLUMN member_admission; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250513_group_scope.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250513_group_scope.hs new file mode 100644 index 0000000000..cca2cff969 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250513_group_scope.hs @@ -0,0 +1,64 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250513_group_scope where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250513_group_scope :: Text +m20250513_group_scope = + T.pack + [r| +ALTER TABLE group_members ADD COLUMN support_chat_ts TIMESTAMPTZ; +ALTER TABLE group_members ADD COLUMN support_chat_items_unread BIGINT NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention BIGINT NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_mentions BIGINT NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_last_msg_from_member_ts TIMESTAMPTZ; + +ALTER TABLE groups ADD COLUMN members_require_attention BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; +ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +CREATE INDEX idx_chat_items_group_scope_group_member_id ON chat_items(group_scope_group_member_id); + +CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_ts +); + +CREATE INDEX idx_chat_items_group_scope_item_status ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_status, + item_ts +); +|] + +down_m20250513_group_scope :: Text +down_m20250513_group_scope = + T.pack + [r| +DROP INDEX idx_chat_items_group_scope_item_status; + +DROP INDEX idx_chat_items_group_scope_item_ts; + +DROP INDEX idx_chat_items_group_scope_group_member_id; + +ALTER TABLE chat_items DROP COLUMN group_scope_tag; +ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; + +ALTER TABLE groups DROP COLUMN members_require_attention; + +ALTER TABLE group_members DROP COLUMN support_chat_ts; +ALTER TABLE group_members DROP COLUMN support_chat_items_unread; +ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; +ALTER TABLE group_members DROP COLUMN support_chat_items_mentions; +ALTER TABLE group_members DROP COLUMN support_chat_last_msg_from_member_ts; +|] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 4fca5fb1a4..38b2615686 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -18,6 +18,7 @@ module Simplex.Chat.Store.Profiles ( AutoAccept (..), UserMsgReceiptSettings (..), UserContactLink (..), + GroupLinkInfo (..), createUserRecord, createUserRecordAt, getUsersInfo, @@ -42,12 +43,15 @@ module Simplex.Chat.Store.Profiles setUserProfileContactLink, getUserContactProfiles, createUserContactLink, - getUserAddressConnections, + getUserAddressConnection, getUserContactLinks, deleteUserAddress, getUserAddress, getUserContactLinkById, + getGroupLinkInfo, getUserContactLinkByConnReq, + getUserContactLinkViaShortLink, + setUserContactLinkShortLink, getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, @@ -98,7 +102,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 @@ -107,6 +111,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode) +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) #if defined(dbPostgres) @@ -150,6 +155,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) +-- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] getUsersInfo db = getUsers db >>= mapM getUserInfo where @@ -174,7 +180,8 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo SELECT COUNT(1) FROM chat_items i JOIN groups g USING (group_id) - WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + WHERE i.user_id = ? AND i.item_status = ? + AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) |] (userId, CISRcvNew) pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} @@ -322,11 +329,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 @@ -342,40 +351,35 @@ 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 -getUserAddressConnections :: DB.Connection -> VersionRangeChat -> User -> ExceptT StoreError IO [Connection] -getUserAddressConnections db vr User {userId} = do - cs <- liftIO getUserAddressConnections_ - if null cs then throwError SEUserContactLinkNotFound else pure cs - where - getUserAddressConnections_ :: IO [Connection] - getUserAddressConnections_ = - map (toConnection vr) - <$> DB.query - db - [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM connections c - JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id - WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL - |] - (userId, userId) +getUserAddressConnection :: DB.Connection -> VersionRangeChat -> User -> ExceptT StoreError IO Connection +getUserAddressConnection db vr User {userId} = do + ExceptT . firstRow (toConnection vr) SEUserContactLinkNotFound $ + DB.query + db + [sql| + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + |] + (userId, userId) getUserContactLinks :: DB.Connection -> VersionRangeChat -> User -> IO [(Connection, UserContact)] getUserContactLinks db vr User {userId} = @@ -446,11 +450,17 @@ data UserMsgReceiptSettings = UserMsgReceiptSettings deriving (Show) data UserContactLink = UserContactLink - { connReqContact :: ConnReqContact, + { connLinkContact :: CreatedLinkContact, autoAccept :: Maybe AutoAccept } deriving (Show) +data GroupLinkInfo = GroupLinkInfo + { groupId :: GroupId, + memberRole :: GroupMemberRole + } + deriving (Show) + data AutoAccept = AutoAccept { businessAddress :: Bool, -- possibly, it can be wrapped together with acceptIncognito, or AutoAccept made sum type acceptIncognito :: IncognitoEnabled, @@ -462,47 +472,72 @@ $(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 GroupId, GroupMemberRole) +getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupLinkInfo) getUserContactLinkById db userId userContactLinkId = - ExceptT . firstRow (\(ucl :. (groupId_, mRole_)) -> (toUserContactLink ucl, groupId_, fromMaybe GRMember mRole_)) SEUserContactLinkNotFound $ + ExceptT . firstRow (\(ucl :. gli) -> (toUserContactLink ucl, toGroupLinkInfo gli)) SEUserContactLinkNotFound $ 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 = ? + WHERE user_id = ? AND user_contact_link_id = ? |] (userId, userContactLinkId) +toGroupLinkInfo :: (Maybe GroupId, Maybe GroupMemberRole) -> Maybe GroupLinkInfo +toGroupLinkInfo (groupId_, mRole_) = + (\groupId -> GroupLinkInfo {groupId, memberRole = fromMaybe GRMember mRole_}) + <$> groupId_ + +getGroupLinkInfo :: DB.Connection -> UserId -> GroupId -> IO (Maybe GroupLinkInfo) +getGroupLinkInfo db userId groupId = + fmap join $ maybeFirstRow toGroupLinkInfo $ + DB.query + db + [sql| + SELECT group_id, group_link_member_role + FROM user_contact_links + WHERE user_id = ? AND group_id = ? + |] + (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 + |] + +setUserContactLinkShortLink :: DB.Connection -> Int64 -> ShortLinkContact -> IO () +setUserContactLinkShortLink db userContactLinkId shortLink = + DB.execute + db + [sql| + UPDATE user_contact_links + SET short_link_contact = ? + WHERE user_contact_link_id = ? + |] + (shortLink, userContactLinkId) 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/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 4921369b10..cc626ddcf0 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -10,6 +10,7 @@ import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeASCII, encodeUtf8) +import qualified Data.X509 as X import Data.Word (Word16) import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Shared @@ -66,7 +67,7 @@ remoteHostQuery = FROM remote_hosts |] -toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519, Maybe Text, Maybe Text, Maybe Word16) -> RemoteHost +toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject X.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519, Maybe Text, Maybe Text, Maybe Word16) -> RemoteHost toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey, ifaceName_, ifaceAddr_, bindPort_) = RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing, bindAddress_, bindPort_} where @@ -133,7 +134,7 @@ toRemoteCtrl :: ( RemoteCtrlId, Text, C.APrivateSignKey, - C.SignedObject C.Certificate, + C.SignedObject X.Certificate, C.KeyHash, C.PublicKeyEd25519, C.PrivateKeyX25519, diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f8bdc0d788..871cb62220 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -124,6 +124,13 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl +import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history +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.M20250402_short_links +import Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission +import Simplex.Chat.Store.SQLite.Migrations.M20250513_group_scope import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -247,7 +254,14 @@ schemaMigrations = ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), ("20241230_reports", m20241230_reports, Just down_m20241230_reports), ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), - ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl) + ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), + ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history), + ("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), + ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links), + ("20250512_member_admission", m20250512_member_admission, Just down_m20250512_member_admission), + ("20250513_group_scope", m20250513_group_scope, Just down_m20250513_group_scope) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs new file mode 100644 index 0000000000..afc8544b4a --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250122_chat_items_include_in_history :: Query +m20250122_chat_items_include_in_history = + [sql| +ALTER TABLE chat_items ADD COLUMN include_in_history INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); + +UPDATE chat_items +SET include_in_history = 1 +WHERE group_id IS NOT NULL + AND item_content_tag IN ('rcvMsgContent', 'sndMsgContent') + AND msg_content_tag NOT IN ('report'); + +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses(chat_item_id, group_member_id); +|] + +down_m20250122_chat_items_include_in_history :: Query +down_m20250122_chat_items_include_in_history = + [sql| +DROP INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id; + +DROP INDEX idx_chat_items_groups_history; + +ALTER TABLE chat_items DROP COLUMN include_in_history; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs new file mode 100644 index 0000000000..a920c17859 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250126_mentions :: Query +m20250126_mentions = + [sql| +ALTER TABLE chat_items ADD COLUMN user_mention INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE chat_item_mentions ( + chat_item_mention_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BLOB NOT NULL, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + display_name TEXT NOT NULL +); + +CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); + +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items(user_id, group_id, item_status, user_mention); +|] + +down_m20250126_mentions :: Query +down_m20250126_mentions = + [sql| +DROP INDEX idx_chat_items_groups_user_mention; + +DROP INDEX idx_chat_item_mentions_group_id; +DROP INDEX idx_chat_item_mentions_chat_item_id; +DROP INDEX idx_chat_item_mentions_display_name; +DROP INDEX idx_chat_item_mentions_member_id; + +DROP TABLE chat_item_mentions; +ALTER TABLE chat_items DROP COLUMN user_mention; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs new file mode 100644 index 0000000000..91b3f688c1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs @@ -0,0 +1,66 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250129_delete_unused_contacts :: Query +m20250129_delete_unused_contacts = + [sql| +CREATE TEMPORARY TABLE temp_delete_contacts ( + contact_id INTEGER PRIMARY KEY, + contact_profile_id INTEGER NOT NULL, + local_display_name TEXT NOT NULL +); + +INSERT INTO temp_delete_contacts(contact_id, contact_profile_id, local_display_name) +SELECT contact_id, contact_profile_id, local_display_name +FROM contacts +WHERE contact_used = 0 AND is_user = 0 + AND contact_id NOT IN (SELECT contact_id FROM users) + AND contact_id NOT IN (SELECT contact_id FROM contact_requests); + +CREATE TEMPORARY TABLE temp_delete_profiles (contact_profile_id INTEGER PRIMARY KEY); + +INSERT OR IGNORE INTO temp_delete_profiles(contact_profile_id) +SELECT custom_user_profile_id FROM connections +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts) + AND custom_user_profile_id IS NOT NULL; + +UPDATE group_members SET contact_id = NULL +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM connections +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM contacts +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM contact_profiles +WHERE + (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_profiles) + OR contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_contacts)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections); + +DELETE FROM display_names +WHERE local_display_name IN (SELECT local_display_name FROM temp_delete_contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests); + +DROP TABLE temp_delete_contacts; +DROP TABLE temp_delete_profiles; +|] + +down_m20250129_delete_unused_contacts :: Query +down_m20250129_delete_unused_contacts = + [sql| +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs new file mode 100644 index 0000000000..c0dbd05fcf --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250130_indexes :: Query +m20250130_indexes = + [sql| +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); + +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_chat_items_shared_msg_id; +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items(group_id, shared_msg_id); + + +DROP INDEX idx_groups_host_conn_custom_user_profile_id; +ALTER TABLE groups DROP COLUMN host_conn_custom_user_profile_id; +|] + +down_m20250130_indexes :: Query +down_m20250130_indexes = + [sql| +ALTER TABLE groups ADD COLUMN host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups(host_conn_custom_user_profile_id); + + +DROP INDEX idx_chat_items_group_id_shared_msg_id; +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); + +DROP INDEX idx_connections_group_member_id; + +DROP INDEX idx_chat_items_group_id; +|] 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/M20250512_member_admission.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250512_member_admission.hs new file mode 100644 index 0000000000..e1f45beef1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250512_member_admission.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250512_member_admission :: Query +m20250512_member_admission = + [sql| +ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; +|] + +down_m20250512_member_admission :: Query +down_m20250512_member_admission = + [sql| +ALTER TABLE group_profiles DROP COLUMN member_admission; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250513_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250513_group_scope.hs new file mode 100644 index 0000000000..34ec6ee382 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250513_group_scope.hs @@ -0,0 +1,61 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250513_group_scope where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250513_group_scope :: Query +m20250513_group_scope = + [sql| +ALTER TABLE group_members ADD COLUMN support_chat_ts TEXT; +ALTER TABLE group_members ADD COLUMN support_chat_items_unread INTEGER NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_mentions INTEGER NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_last_msg_from_member_ts TEXT; + +ALTER TABLE groups ADD COLUMN members_require_attention INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; +ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +CREATE INDEX idx_chat_items_group_scope_group_member_id ON chat_items(group_scope_group_member_id); + +CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_ts +); + +CREATE INDEX idx_chat_items_group_scope_item_status ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_status, + item_ts +); +|] + +down_m20250513_group_scope :: Query +down_m20250513_group_scope = + [sql| +DROP INDEX idx_chat_items_group_scope_item_status; + +DROP INDEX idx_chat_items_group_scope_item_ts; + +DROP INDEX idx_chat_items_group_scope_group_member_id; + +ALTER TABLE chat_items DROP COLUMN group_scope_tag; +ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; + +ALTER TABLE groups DROP COLUMN members_require_attention; + +ALTER TABLE group_members DROP COLUMN support_chat_ts; +ALTER TABLE group_members DROP COLUMN support_chat_items_unread; +ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; +ALTER TABLE group_members DROP COLUMN support_chat_items_mentions; +ALTER TABLE group_members DROP COLUMN support_chat_last_msg_from_member_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 new file mode 100644 index 0000000000..13215dcb75 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -0,0 +1,1181 @@ +Query: + SELECT + f.snd_file_id, f.snd_file_entity_id, f.user_id, f.num_recipients, f.prefix_path, + c.snd_file_chunk_id, c.chunk_no, c.chunk_offset, c.chunk_size, c.digest, + r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE r.snd_file_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + m.msg_type, m.msg_flags, m.msg_body, m.pq_encryption, m.internal_ts, m.internal_snd_id, s.previous_msg_hash, + s.retry_int_slow, s.retry_int_fast, s.msg_encrypt_key, s.padded_msg_len, sb.agent_msg + FROM messages m + JOIN snd_messages s ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id + LEFT JOIN snd_message_bodies sb ON sb.snd_message_body_id = s.snd_message_body_id + WHERE m.conn_id = ? AND m.internal_id = ? + +Plan: +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) +SEARCH sb USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT + r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.snd_file_chunk_id = ? + +Plan: +SEARCH r USING INDEX idx_snd_file_chunk_replicas_snd_file_chunk_id (snd_file_chunk_id=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT command_id FROM commands + WHERE conn_id = ? AND host = ? AND port = ? AND failed = 0 + ORDER BY created_at ASC, command_id ASC + LIMIT 1 + +Plan: +SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?) + +Query: + SELECT command_id FROM commands + WHERE conn_id = ? AND host IS NULL AND port IS NULL AND failed = 0 + ORDER BY created_at ASC, command_id ASC + LIMIT 1 + +Plan: +SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?) + +Query: + SELECT rcv_file_chunk_id, chunk_no, chunk_size, digest, tmp_path + FROM rcv_file_chunks + WHERE rcv_file_id = ? + +Plan: +SEARCH rcv_file_chunks USING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) + +Query: + SELECT snd_file_chunk_id, chunk_no, chunk_offset, chunk_size, digest + FROM snd_file_chunks + WHERE snd_file_id = ? + +Plan: +SEARCH snd_file_chunks USING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) + +Query: + DELETE FROM snd_message_bodies + WHERE NOT EXISTS (SELECT 1 FROM snd_messages WHERE snd_message_body_id = ?) + AND snd_message_body_id = ? + +Plan: +SEARCH snd_message_bodies USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 1 +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) + +Query: + SELECT + f.rcv_file_id, f.rcv_file_entity_id, f.user_id, c.rcv_file_chunk_id, c.chunk_no, c.chunk_size, c.digest, f.tmp_path, c.tmp_path, + r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries, + f.approved_relays, f.redirect_entity_id + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE r.rcv_file_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.rcv_file_chunk_id = ? + +Plan: +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chunk_id=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT c.corr_id, cs.user_id, c.command + FROM commands c + JOIN connections cs USING (conn_id) + WHERE c.command_id = ? + +Plan: +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cs USING PRIMARY KEY (conn_id=?) + +Query: + SELECT internal_id + FROM snd_message_deliveries + WHERE conn_id = ? AND snd_queue_id = ? AND failed = 0 AND internal_id <= ? + ORDER BY internal_id ASC + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=? AND failed=? AND internal_id= ? + AND failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_deleted_snd_chunk_replicas_xftp_server_id (xftp_server_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.rcv_file_chunk_replica_id, f.rcv_file_id + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE s.xftp_host = ? AND s.xftp_port = ? AND s.xftp_key_hash = ? + AND r.received = 0 AND r.replica_number = 1 + AND f.status = ? AND f.deleted = 0 AND f.created_at >= ? + AND f.failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_pending (received=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.snd_file_chunk_replica_id, f.snd_file_id + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE s.xftp_host = ? AND s.xftp_port = ? AND s.xftp_key_hash = ? + AND r.replica_status = ? AND r.replica_number = 1 + AND (f.status = ? OR f.status = ?) AND f.deleted = 0 AND f.created_at >= ? + AND f.failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_snd_file_chunk_replicas_pending (replica_status=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT rcpt_status, snd_message_body_id FROM snd_messages + WHERE NOT EXISTS (SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND internal_id = ? AND failed = 0) + AND conn_id = ? AND internal_id = ? + +Plan: +SEARCH snd_messages USING PRIMARY KEY (conn_id=?) +SCALAR SUBQUERY 1 +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) + +Query: + SELECT rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, deleted, redirect_id, redirect_entity_id, redirect_size, redirect_digest + FROM rcv_files + WHERE rcv_file_id = ? + +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT rcv_file_id + FROM rcv_files + WHERE status IN (?,?) AND deleted = 0 AND created_at >= ? + AND failed = 0 + ORDER BY created_at ASC LIMIT 1 + +Plan: +SEARCH rcv_files USING INDEX idx_rcv_files_status_created_at (status=? AND created_at>?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, digest, prefix_path, key, nonce, status, deleted, redirect_size, redirect_digest + FROM snd_files + WHERE snd_file_id = ? + +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT snd_file_id + FROM snd_files + WHERE status IN (?,?,?) AND deleted = 0 AND created_at >= ? + AND failed = 0 + ORDER BY created_at ASC LIMIT 1 + +Plan: +SEARCH snd_files USING INDEX idx_snd_files_status_created_at (status=? AND created_at>?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT MAX(internal_id) + FROM messages + WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND internal_ts < ? + +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=?) + +Query: + SELECT user_id FROM users u + WHERE u.user_id = ? + AND u.deleted = ? + AND NOT EXISTS (SELECT c.conn_id FROM connections c WHERE c.user_id = u.user_id) + +Plan: +SEARCH u USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH c USING COVERING INDEX idx_connections_user (user_id=?) + +Query: + INSERT INTO conn_confirmations + (confirmation_id, conn_id, sender_key, e2e_snd_pub_key, ratchet_state, sender_conn_info, smp_reply_queues, smp_client_version, accepted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0); + +Plan: + +Query: + INSERT INTO conn_invitations + (invitation_id, contact_conn_id, cr_invitation, recipient_conn_info, accepted) VALUES (?, ?, ?, ?, 0); + +Plan: + +Query: + INSERT INTO snd_file_chunk_replica_recipients + (snd_file_chunk_replica_id, rcv_replica_id, rcv_replica_key) + VALUES (?,?,?) + +Plan: + +Query: + SELECT + r.internal_id, m.internal_ts, r.broker_id, r.broker_ts, r.external_snd_id, r.integrity, r.internal_hash, + m.msg_type, m.msg_body, m.pq_encryption, s.internal_id, s.rcpt_status, r.user_ack + FROM rcv_messages r + JOIN messages m ON r.conn_id = m.conn_id AND r.internal_id = m.internal_id + JOIN connections c ON r.conn_id = c.conn_id AND c.last_internal_msg_id = r.internal_id + LEFT JOIN snd_messages s ON s.conn_id = r.conn_id AND s.rcpt_internal_id = r.internal_id + WHERE r.conn_id = ? AND r.broker_id = ? + +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH r USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) LEFT-JOIN + +Query: + SELECT + r.internal_id, m.internal_ts, r.broker_id, r.broker_ts, r.external_snd_id, r.integrity, r.internal_hash, + m.msg_type, m.msg_body, m.pq_encryption, s.internal_id, s.rcpt_status, r.user_ack + FROM rcv_messages r + JOIN messages m ON r.conn_id = m.conn_id AND r.internal_id = m.internal_id + LEFT JOIN snd_messages s ON s.conn_id = r.conn_id AND s.rcpt_internal_id = r.internal_id + WHERE r.conn_id = ? AND r.internal_id = ? + +Plan: +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH r USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) LEFT-JOIN + +Query: + SELECT + r.user_id, r.replica_id, r.replica_key, r.chunk_digest, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM deleted_snd_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.deleted_snd_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, + last_external_snd_msg_id, deleted, ratchet_sync_state, pq_support + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM deleted_snd_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_deleted_snd_chunk_replicas_pending (created_at>?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE r.received = 0 AND r.replica_number = 1 + AND f.status = ? AND f.deleted = 0 AND f.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_pending (received=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE r.replica_status = ? AND r.replica_number = 1 + AND (f.status = ? OR f.status = ?) AND f.deleted = 0 AND f.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_snd_file_chunk_replicas_pending (replica_status=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT c.host, c.port, COALESCE(c.server_key_hash, s.key_hash) + FROM commands c + LEFT JOIN servers s ON s.host = c.host AND s.port = c.port + WHERE conn_id = ? + +Plan: +SEARCH c USING INDEX idx_commands_conn_id (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) LEFT-JOIN +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT ntf_host, ntf_port, ntf_key_hash + FROM ntf_tokens_to_delete + +Plan: +SCAN ntf_tokens_to_delete +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT confirmation_id, ratchet_state, own_conn_info, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version + FROM conn_confirmations + WHERE conn_id = ? AND accepted = 1; + +Plan: +SEARCH conn_confirmations USING INDEX idx_conn_confirmations_conn_id (conn_id=?) + +Query: + SELECT conn_id, ratchet_state, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version + FROM conn_confirmations + WHERE confirmation_id = ?; + +Plan: +SEARCH conn_confirmations USING PRIMARY KEY (confirmation_id=?) + +Query: + SELECT contact_conn_id, cr_invitation, recipient_conn_info, own_conn_info, accepted + FROM conn_invitations + WHERE invitation_id = ? + AND accepted = 0 + +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: + SELECT last_internal_msg_id, last_internal_rcv_msg_id, last_external_snd_msg_id, last_rcv_msg_hash + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT last_internal_msg_id, last_internal_snd_msg_id, last_snd_msg_hash + FROM connections + WHERE conn_id = ? + +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 + JOIN messages m ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id + WHERE s.conn_id = ? AND s.internal_snd_id = ? + +Plan: +SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) + +Query: + DELETE FROM conn_confirmations + WHERE conn_id = ? + +Plan: +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) + +Query: + INSERT INTO connections + (user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, pq_support, duplex_handshake) VALUES (?,?,?,?,?,?,?) + +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) + VALUES (?,?,?,?,?,?,?,?,?); + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: + INSERT INTO messages + (conn_id, internal_id, internal_ts, internal_rcv_id, internal_snd_id, msg_type, msg_flags, msg_body, pq_encryption) + VALUES + (?,?,?,?,?,?,?,?,?); + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: + INSERT INTO ratchets + (conn_id, ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (conn_id) DO UPDATE SET + ratchet_state = EXCLUDED.ratchet_state, + x3dh_priv_key_1 = NULL, + x3dh_priv_key_2 = NULL, + x3dh_pub_key_1 = EXCLUDED.x3dh_pub_key_1, + x3dh_pub_key_2 = EXCLUDED.x3dh_pub_key_2, + pq_priv_kem = NULL, + pq_pub_kem = EXCLUDED.pq_pub_kem + +Plan: + +Query: + INSERT INTO ratchets (conn_id, ratchet_state) + VALUES (?, ?) + ON CONFLICT (conn_id) DO UPDATE SET + ratchet_state = ?, + x3dh_priv_key_1 = NULL, + x3dh_priv_key_2 = NULL, + x3dh_pub_key_1 = NULL, + x3dh_pub_key_2 = NULL, + pq_priv_kem = NULL, + pq_pub_kem = NULL + +Plan: + +Query: + INSERT INTO rcv_messages + ( conn_id, rcv_queue_id, internal_rcv_id, internal_id, external_snd_id, + broker_id, broker_ts, + internal_hash, external_prev_snd_hash, integrity) + VALUES + (?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_id=? AND internal_rcv_id=?) + +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, 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: + +Query: + INSERT INTO snd_file_chunk_replicas + (snd_file_chunk_id, replica_number, xftp_server_id, replica_id, replica_key, replica_status) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO snd_messages + ( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash, msg_encrypt_key, padded_msg_len, snd_message_body_id) + VALUES + (?,?,?,?,?,?,?,?) + +Plan: +SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_id=? AND internal_snd_id=?) + +Query: + INSERT INTO snd_queues + (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, + queue_mode=EXCLUDED.queue_mode, + conn_id=EXCLUDED.conn_id, + snd_public_key=EXCLUDED.snd_public_key, + snd_private_key=EXCLUDED.snd_private_key, + e2e_pub_key=EXCLUDED.e2e_pub_key, + e2e_dh_secret=EXCLUDED.e2e_dh_secret, + status=EXCLUDED.status, + snd_queue_id=EXCLUDED.snd_queue_id, + snd_primary=EXCLUDED.snd_primary, + replace_snd_queue_id=EXCLUDED.replace_snd_queue_id, + smp_client_version=EXCLUDED.smp_client_version, + server_key_hash=EXCLUDED.server_key_hash + +Plan: + +Query: + SELECT rcv_replica_id, rcv_replica_key + FROM snd_file_chunk_replica_recipients + WHERE snd_file_chunk_replica_id = ? + +Plan: +SEARCH snd_file_chunk_replica_recipients USING INDEX idx_snd_file_chunk_replica_recipients_snd_file_chunk_replica_id (snd_file_chunk_replica_id=?) + +Query: + UPDATE conn_confirmations + SET accepted = 1, + own_conn_info = ? + WHERE confirmation_id = ? + +Plan: +SEARCH conn_confirmations USING PRIMARY KEY (confirmation_id=?) + +Query: + UPDATE conn_invitations + SET accepted = 1, + own_conn_info = ? + WHERE invitation_id = ? + +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: + UPDATE connections + SET last_external_snd_msg_id = ?, + last_rcv_msg_hash = ? + WHERE conn_id = ? + AND last_internal_rcv_msg_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_internal_msg_id = ?, + last_internal_rcv_msg_id = ? + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_internal_msg_id = ?, + last_internal_snd_msg_id = ? + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_snd_msg_hash = ? + WHERE conn_id = ? + AND last_internal_snd_msg_id = ?; + +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 = ? + WHERE conn_id = ? + +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE rcv_queues + SET deleted = 1 + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET e2e_dh_secret = ?, + status = ?, + smp_client_version = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET status = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET switch_status = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE snd_queues + SET status = ? + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: + UPDATE snd_queues + SET switch_status = ? + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +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.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 + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_snd_queue_id (conn_id=?) +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.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.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 + WHERE q.conn_id = ? AND q.deleted = 0 +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_rcv_queue_id (conn_id=?) +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.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.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 + WHERE q.conn_id = ? AND q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 0 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +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.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.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 + WHERE q.conn_id = ? AND q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 1 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +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.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.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 + WHERE q.conn_id = ? AND q.rcv_queue_id = ? AND q.deleted = 0 +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) +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.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.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 + WHERE q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 0 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: DELETE FROM commands WHERE command_id = ? +Plan: +SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) + +Query: DELETE FROM conn_invitations WHERE contact_conn_id = ? AND invitation_id = ? +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: DELETE FROM connections WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=?) +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=?) +SEARCH commands USING COVERING INDEX idx_commands_conn_id (conn_id=?) +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH conn_invitations USING COVERING INDEX idx_conn_invitations_contact_conn_id (contact_conn_id=?) +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) +SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?) +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +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=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) +SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: DELETE FROM ntf_subscriptions WHERE conn_id = ? +Plan: +SEARCH ntf_subscriptions USING PRIMARY KEY (conn_id=?) + +Query: DELETE FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH skipped_messages USING COVERING INDEX idx_skipped_messages_conn_id (conn_id=?) + +Query: DELETE FROM rcv_files WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?) + +Query: DELETE FROM rcv_queues WHERE conn_id = ? AND rcv_queue_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: DELETE FROM snd_files WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_file_chunks USING COVERING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) + +Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? AND internal_id = ? +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM snd_queues WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM users WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_user_id (user_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_user_id (user_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_user_id (user_id=?) +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + +Query: INSERT INTO commands (host, port, corr_id, conn_id, command_tag, command, server_key_hash, created_at) VALUES (?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO deleted_snd_chunk_replicas (user_id, xftp_server_id, replica_id, replica_key, chunk_digest) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO encrypted_rcv_message_hashes (conn_id, hash) VALUES (?,?) +Plan: + +Query: INSERT INTO processed_ratchet_key_hashes (conn_id, hash) VALUES (?,?) +Plan: + +Query: INSERT INTO ratchets (conn_id, x3dh_priv_key_1, x3dh_priv_key_2, pq_priv_kem) VALUES (?, ?, ?, ?) +Plan: + +Query: INSERT INTO rcv_file_chunk_replicas (replica_number, rcv_file_chunk_id, xftp_server_id, replica_id, replica_key) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO rcv_file_chunks (rcv_file_id, chunk_no, chunk_size, digest) VALUES (?,?,?,?) +Plan: + +Query: INSERT INTO rcv_files (rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, redirect_id, redirect_entity_id, redirect_digest, redirect_size, approved_relays) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO servers (host, port, key_hash) VALUES (?,?,?) +Plan: + +Query: INSERT INTO skipped_messages (conn_id, header_key, msg_n, msg_key) VALUES (?, ?, ?, ?) +Plan: + +Query: INSERT INTO snd_file_chunks (snd_file_id, chunk_no, chunk_offset, chunk_size, digest) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO snd_files (snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, prefix_path, key, nonce, status, redirect_size, redirect_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO snd_message_bodies (agent_msg) VALUES (?) RETURNING snd_message_body_id +Plan: +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) + +Query: INSERT INTO snd_message_deliveries (conn_id, snd_queue_id, internal_id) VALUES (?, ?, ?) +Plan: + +Query: INSERT INTO users (user_id) VALUES (1) +Plan: + +Query: INSERT INTO users DEFAULT VALUES +Plan: + +Query: INSERT INTO xftp_servers (xftp_host, xftp_port, xftp_key_hash) VALUES (?,?,?) +Plan: + +Query: SELECT 1 FROM encrypted_rcv_message_hashes WHERE conn_id = ? AND hash = ? LIMIT 1 +Plan: +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=? AND hash=?) + +Query: SELECT 1 FROM processed_ratchet_key_hashes WHERE conn_id = ? AND hash = ? LIMIT 1 +Plan: +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=? AND hash=?) + +Query: SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND failed = 0 LIMIT 1 +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) + +Query: SELECT DISTINCT conn_id FROM snd_message_deliveries WHERE failed = 0 +Plan: +SCAN snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired + +Query: SELECT conn_id FROM connections WHERE user_id = ? +Plan: +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + +Query: SELECT count(1) FROM snd_message_bodies +Plan: +SCAN snd_message_bodies + +Query: SELECT deleted FROM snd_files WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT header_key, msg_n, msg_key FROM skipped_messages WHERE conn_id = ? +Plan: +SEARCH skipped_messages USING INDEX idx_skipped_messages_conn_id (conn_id=?) + +Query: SELECT key_hash FROM servers WHERE host = ? AND port = ? +Plan: +SEARCH servers USING PRIMARY KEY (host=? AND port=?) + +Query: SELECT last_insert_rowid() +Plan: +SCAN CONSTANT ROW + +Query: SELECT ratchet_state FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT rcv_file_id FROM rcv_files WHERE rcv_file_entity_id = ? +Plan: +SEARCH rcv_files USING COVERING INDEX sqlite_autoindex_rcv_files_1 (rcv_file_entity_id=?) + +Query: SELECT rcv_file_id FROM rcv_files WHERE redirect_id = ? +Plan: +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?) + +Query: SELECT rcv_queue_id FROM rcv_queues WHERE conn_id = ? AND host = ? AND port = ? AND snd_id = ? +Plan: +SEARCH rcv_queues USING INDEX sqlite_autoindex_rcv_queues_2 (host=? AND port=? AND snd_id=?) + +Query: SELECT rcv_queue_id FROM rcv_queues WHERE conn_id = ? ORDER BY rcv_queue_id DESC LIMIT 1 +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: SELECT rcv_queue_id, broker_id FROM rcv_messages WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH rcv_messages USING PRIMARY KEY (conn_id=?) + +Query: SELECT snd_file_id FROM snd_files WHERE snd_file_entity_id = ? +Plan: +SEARCH snd_files USING COVERING INDEX idx_snd_files_snd_file_entity_id (snd_file_entity_id=?) + +Query: SELECT snd_queue_id FROM snd_queues WHERE conn_id = ? AND host = ? AND port = ? AND snd_id = ? +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: SELECT snd_queue_id FROM snd_queues WHERE conn_id = ? ORDER BY snd_queue_id DESC LIMIT 1 +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) + +Query: SELECT started_at, servers_stats FROM servers_stats WHERE servers_stats_id = 1 +Plan: +SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_id FROM users WHERE user_id = ? AND deleted = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT x3dh_priv_key_1, x3dh_priv_key_2, pq_priv_kem FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT xftp_server_id FROM xftp_servers WHERE xftp_host = ? AND xftp_port = ? AND xftp_key_hash = ? +Plan: +SEARCH xftp_servers USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) + +Query: UPDATE connections SET deleted = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET deleted_at_wait_delivery = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET enable_ntfs = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET pq_support = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET ratchet_sync_state = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET user_id = ? WHERE conn_id = ? and user_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) + +Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_file_chunks SET tmp_path = ?, updated_at = ? WHERE rcv_file_chunk_id = ? +Plan: +SEARCH rcv_file_chunks USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET deleted = 1, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET key = ?, nonce = ?, chunk_size = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET tmp_path = NULL, error = ?, status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET tmp_path = NULL, status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_messages SET user_ack = ? WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: UPDATE rcv_queues SET last_broker_ts = ? WHERE conn_id = ? AND rcv_queue_id = ? AND (last_broker_ts IS NULL OR last_broker_ts < ?) +Plan: +SEARCH rcv_queues USING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: UPDATE rcv_queues SET rcv_primary = ? WHERE conn_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: UPDATE rcv_queues SET rcv_primary = ?, replace_rcv_queue_id = ? WHERE conn_id = ? AND rcv_queue_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: UPDATE servers_stats SET servers_stats = ?, updated_at = ? WHERE servers_stats_id = 1 +Plan: +SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_file_chunk_replicas SET replica_status = ?, updated_at = ? WHERE snd_file_chunk_replica_id = ? +Plan: +SEARCH snd_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET deleted = 1, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET prefix_path = NULL, status = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET status = ?, digest = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET status = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_messages SET rcpt_internal_id = ?, rcpt_status = ? WHERE conn_id = ? AND internal_snd_id = ? +Plan: +SEARCH snd_messages USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) + +Query: UPDATE snd_messages SET retry_int_slow = ?, retry_int_fast = ? WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: UPDATE snd_messages SET snd_message_body_id = NULL WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: UPDATE snd_queues SET snd_primary = ? WHERE conn_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) + +Query: UPDATE snd_queues SET snd_primary = ?, replace_snd_queue_id = ? WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) + +Query: UPDATE users SET deleted = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql index 86047070fb..e69de29bb2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql @@ -1,2 +0,0 @@ -CREATE INDEX 'chat_items_group_id' ON 'chat_items'('group_id'); --> groups(group_id) -CREATE INDEX 'connections_group_member_id' ON 'connections'('group_member_id'); --> group_members(group_member_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 new file mode 100644 index 0000000000..ea91bced4d --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -0,0 +1,6120 @@ +Query: + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_xcontact_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_requests + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_requests + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ?, + support_chat_last_msg_from_member_ts = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO contact_requests + (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, + created_at, updated_at, xcontact_id, pq_support) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO display_names + (local_display_name, ldn_base, ldn_suffix, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + +Plan: +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT COUNT(1) + FROM chat_items i + JOIN contacts ct USING (contact_id) + WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) AND ct.deleted = 0 + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts (user_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT COUNT(1) + FROM chat_items i + JOIN groups g USING (group_id) + WHERE i.user_id = ? AND i.item_status = ? + AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT conditions_commit, accepted_at, auto_accepted + FROM operator_usage_conditions + WHERE server_operator_id = ? + ORDER BY operator_usage_conditions_id DESC + LIMIT 1 + +Plan: +SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_server_operator_id (server_operator_id=?) + +Query: + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) + +Query: + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status NOT IN (?,?,?) + +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT timed_ttl + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, + full_name = ?, + image = ?, + contact_link = ?, + updated_at = ? + WHERE contact_profile_id IN ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? + AND contact_request_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE xftp_file_descriptions + SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ? + WHERE file_descr_id = ? + +Plan: +SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO contacts ( + user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + created_at, updated_at, chat_ts + ) VALUES (?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages ( + msg_sent, chat_msg_event, msg_body, connection_id, group_id, + shared_msg_id, shared_msg_id_user, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, + p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + FROM contacts c + JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id + WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 + +Plan: +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? + LIMIT 1 + +Plan: +SEARCH cr USING INDEX idx_contact_requests_xcontact_id (xcontact_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_ts > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_ts = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=? AND item_ts>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=? AND item_ts=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT author_group_member_id, forwarded_by_group_member_id + FROM messages + WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 + +Plan: +SEARCH messages USING INDEX idx_messages_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) + +Query: + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id + FROM chat_items + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id + FROM chat_items + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT ? + +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 + WHERE user_id = ? AND user_contact_link_id = ? + +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, + created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, + conn_chat_version, peer_chat_min_version, peer_chat_max_version + FROM connections + WHERE user_id = ? AND agent_conn_id = ? + +Plan: +SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) + +Query: + SELECT ct.contact_id + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences, gp.member_admission + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? + WHERE s.group_snd_item_status_id IS NULL + AND i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) +SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) LEFT-JOIN + +Query: + SELECT i.chat_item_id, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN contacts c ON m.contact_id = c.contact_id + LEFT JOIN chat_items i ON i.user_id = m.user_id + AND i.group_id = m.group_id + AND m.group_member_id = i.group_member_id + AND i.shared_msg_id = ? + WHERE m.user_id = ? AND m.group_id = ? AND m.member_id = ? + +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) LEFT-JOIN + +Query: + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + LIMIT 1 + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ldn_suffix FROM display_names + WHERE user_id = ? AND ldn_base = ? + ORDER BY ldn_suffix DESC + LIMIT 1 + +Plan: +SEARCH display_names USING COVERING INDEX sqlite_autoindex_display_names_2 (user_id=? AND ldn_base=?) + +Query: + SELECT s.contact_id, g.group_id, s.group_member_id + FROM sent_probes s + LEFT JOIN contacts c ON s.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON s.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + JOIN sent_probe_hashes h ON h.sent_probe_id = s.sent_probe_id + WHERE s.user_id = ? AND s.probe = ? + AND (h.contact_id = ? OR h.group_member_id = ?) + +Plan: +SEARCH s USING INDEX sqlite_autoindex_sent_probes_1 (user_id=? AND probe=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? + RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: + DELETE FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: + INSERT INTO chat_item_reactions + (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_reactions + (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (local_display_name, user_id, group_profile_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages + (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO server_operators + (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO usage_conditions + (usage_conditions_id, conditions_commit, notified_at, created_at) + VALUES (?,?,?,?) + +Plan: + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- CIMeta forwardedByMember + i.forwarded_by_group_member_id, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + -- quoted ChatItem + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, + -- quoted GroupMember + rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, + rm.created_at, rm.updated_at, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, + -- deleted by GroupMember + dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, + dbm.created_at, dbm.updated_at, + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id + LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) + LEFT JOIN group_members m ON m.group_member_id = i.group_member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) + LEFT JOIN group_members dbm ON dbm.group_member_id = i.item_deleted_by_group_member_id + LEFT JOIN contact_profiles dbp ON dbp.contact_profile_id = COALESCE(dbm.member_profile_id, dbm.contact_profile_id) + WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN +SEARCH gsm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH ri USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) LEFT-JOIN +SEARCH rm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH rp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH dbm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH dbp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN +SEARCH ri USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) LEFT-JOIN + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + +Plan: +SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, + -- via GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +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 + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, + mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, + mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + FROM groups g + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu USING (group_id) + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT 1 FROM users + WHERE (user_id = ? AND local_display_name = ?) + OR contact_id = ? + LIMIT 1 + +Plan: +MULTI-INDEX OR +INDEX 1 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +INDEX 2 +SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + SELECT DISTINCT f.file_id + FROM files f + JOIN snd_files s USING (file_id) + WHERE f.user_id = ? + AND s.file_status IN (?, ?, ?) + AND s.file_descr_id IS NULL + AND s.file_inline IS NULL + AND s.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH s USING INDEX idx_snd_files_file_descr_id (file_descr_id=? AND file_id=?) + +Query: + SELECT agent_conn_id FROM ( + SELECT + agent_conn_id, + (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord + FROM connections + WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? + ORDER BY conn_ord DESC, created_at DESC + LIMIT 1 + ) c + +Plan: +SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? + ORDER BY created_at DESC, chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NULL AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) + +Query: + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id + FROM chat_items + WHERE chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id + FROM chat_items + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) + ORDER BY item_ts ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at + FROM chat_item_moderations + WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + LIMIT 1 + +Plan: +SEARCH chat_item_moderations USING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) + +Query: + SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + FROM connections + WHERE agent_conn_id = ? + +Plan: +SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) + +Query: + SELECT contact_id, group_id + FROM files + WHERE user_id = ? AND file_id = ? + LIMIT 1 + +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT count (m.group_member_id) + FROM groups g + JOIN group_members m USING (group_id) + WHERE g.user_id = ? + AND g.group_id = ? + AND m.member_status NOT IN (?,?,?,?,?) + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) + +Query: + SELECT ct.contact_id + FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL + +Plan: +SEARCH cp USING COVERING INDEX idx_contact_profiles_contact_link (user_id=? AND contact_link=?) +SEARCH ct USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) +SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN + +Query: + SELECT ct.contact_id + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name + FROM contacts ct + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.agent_conn_id IN (SELECT conn_id FROM temp_conn_ids) + AND c.conn_type = ? + AND ct.deleted = 0 + +Plan: +SEARCH c USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) +LIST SUBQUERY 1 +SCAN temp_conn_ids +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT f.file_id + FROM files f + JOIN rcv_files r USING (file_id) + WHERE f.user_id = ? AND r.file_status IN (?, ?) + AND r.rcv_file_inline IS NULL + AND r.file_descr_id IS NULL + AND r.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?,?) + LIMIT 1 + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT group_id + 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 group_id, group_member_id + FROM group_members + WHERE user_id = ? AND local_display_name = ? + AND member_status IN (?,?) AND member_category != ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + JOIN group_members m ON m.group_id = i.group_id + AND ((i.group_member_id IS NULL AND m.member_category = ?) + OR i.group_member_id = m.group_member_id) + WHERE i.user_id = ? AND i.group_id = ? AND m.member_id = ? AND i.shared_msg_id = ? + ORDER BY i.chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH i USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + JOIN group_members m ON m.group_member_id = i.group_member_id + WHERE i.user_id = ? AND i.group_id = ? AND m.local_display_name = ? AND i.item_text like ? + ORDER BY i.chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id + FROM chat_items i + JOIN groups g ON g.chat_item_id = i.chat_item_id + WHERE g.user_id = ? AND g.group_id = ? + LIMIT 1 + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT member_status + FROM group_members + WHERE group_id = ? AND user_id = ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: + SELECT r.file_id + FROM rcv_files r + JOIN files f ON f.file_id = r.file_id + WHERE f.user_id = ? AND r.file_status = ? + AND r.to_receive = 1 AND r.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, + f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, + f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, + c.connection_id, c.agent_conn_id + FROM rcv_files r + JOIN files f USING (file_id) + LEFT JOIN connections c ON r.file_id = c.rcv_file_id + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = r.group_member_id + WHERE f.user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_rcv_file_id (rcv_file_id=?) LEFT-JOIN +SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT r.probe, r.contact_id, g.group_id, r.group_member_id + FROM received_probes r + LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON r.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL + +Plan: +SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +LIST SUBQUERY 1 +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0 + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ?, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_profiles + SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? + WHERE group_profile_id IN ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE user_contact_links + SET auto_accept = ?, business_address = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? + 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: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contacts ( + user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + contact_group_member_id, contact_grp_inv_sent, created_at, updated_at, chat_ts + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO msg_deliveries + (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at, delivery_status) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at, auto_accepted) + VALUES (?,?,?,?,?) + +Plan: + +Query: + INSERT INTO remote_hosts + (host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + +Plan: + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + AND ct.deleted = ? + AND ( + c.connection_id = ( + SELECT cc_connection_id FROM ( + SELECT + cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, + (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC + LIMIT 1 + ) cc + ) + OR c.connection_id IS NULL + ) + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 2 +CO-ROUTINE cc +SEARCH cc USING INDEX idx_connections_contact_id (contact_id=?) +USE TEMP B-TREE FOR ORDER BY +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, short_link_inv, local_alias, created_at, updated_at + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' + AND updated_at < ? ORDER BY updated_at DESC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated_at ? ORDER BY updated_at ASC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated_at>?) + +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, short_link_inv, local_alias, created_at, updated_at + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' + ORDER BY updated_at DESC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) + +Query: + SELECT + contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts + FROM calls + ORDER BY call_ts ASC + +Plan: +SCAN calls +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' + ) + AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=? AND updated_at ? ORDER BY cr.updated_at ASC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=? AND updated_at>?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY cr.updated_at DESC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.contact_request_id = ? + +Plan: +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + created_at, updated_at, chat_ts, favorite, unread_chat + FROM note_folders + WHERE user_id = ? + AND note_folder_id = ? + +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ( + LOWER(ct.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.local_alias) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.favorite = 1 + OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts 0) + AND ct.chat_ts > ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ct.favorite = 1 + AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ct.favorite = 1 + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? + AND ( + LOWER(g.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND (g.favorite = 1 + OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts 0) + AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND g.favorite = 1 + AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND g.favorite = 1 + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.favorite = 1 + OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT COUNT(1) + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT COUNT(1) + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? + +Plan: +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + uc.user_contact_link_id, uc.conn_req_contact, uc.group_id + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? + +Plan: +SEARCH c USING INDEX idx_connections_updated_at (user_id=?) +SEARCH uc USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT chat_item_id + FROM chat_item_messages + WHERE message_id IN ( + SELECT message_id + FROM msg_deliveries + WHERE connection_id = ? AND agent_msg_id = ? + ) + +Plan: +SEARCH chat_item_messages USING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) +LIST SUBQUERY 1 +SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) + ORDER BY created_at ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_sent = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + ORDER BY created_at ASC, chat_item_id ASC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) + ORDER BY created_at ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + ORDER BY created_at ASC, chat_item_id ASC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX chat_items_note_folder_id (note_folder_id=?) + +Query: + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, timed_delete_at + FROM chat_items + WHERE user_id = ? AND timed_delete_at IS NOT NULL AND timed_delete_at <= ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_timed_delete_at (user_id=? AND timed_delete_at>? AND timed_delete_at= ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) + +Query: + SELECT reaction + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: + SELECT reaction + FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? + GROUP BY reaction + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + GROUP BY reaction + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT rf.file_id, f.contact_id, f.group_id, f.note_folder_id + FROM rcv_files rf + JOIN files f ON f.file_id = rf.file_id + WHERE rf.agent_rcv_file_id = ? + +Plan: +SCAN rf +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT s.file_id, s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.group_member_id, c.local_display_name, m.local_display_name + FROM msg_deliveries d + JOIN snd_files s ON s.connection_id = d.connection_id AND s.last_inline_msg_delivery_id = d.msg_delivery_id + JOIN files f ON f.file_id = s.file_id + LEFT JOIN contacts c USING (contact_id) + LEFT JOIN group_members m USING (group_member_id) + WHERE d.connection_id = ? AND d.agent_msg_id = ? AND f.user_id = ? + AND (s.file_descr_id IS NOT NULL OR s.file_inline IS NOT NULL) + +Plan: +SEARCH d USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) +SEARCH s USING INDEX idx_snd_files_last_inline_msg_delivery_id (last_inline_msg_delivery_id=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.connection_id, c.agent_conn_id, s.group_member_id, + cs.local_display_name, m.local_display_name + FROM snd_files s + JOIN files f USING (file_id) + JOIN connections c USING (connection_id) + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = s.group_member_id + WHERE f.user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING PRIMARY KEY (file_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled + FROM protocol_servers + WHERE user_id = ? AND protocol = ? + +Plan: +SEARCH protocol_servers USING INDEX idx_smp_servers_user_id (user_id=?) + +Query: + SELECT to_group_member_id + FROM group_member_intros + WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) + AND intro_chat_protocol_version >= ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) + +Query: + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE conditions_commit = ( + SELECT conditions_commit + FROM operator_usage_conditions + ORDER BY accepted_at DESC + LIMIT 1 + ) + +Plan: +SEARCH usage_conditions USING INDEX sqlite_autoindex_usage_conditions_1 (conditions_commit=?) +SCALAR SUBQUERY 1 +SCAN operator_usage_conditions +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE usage_conditions_id = ? + +Plan: +SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id = ? RETURNING chat_item_id +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id IS NULL AND item_sent = 1 RETURNING chat_item_id +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id IS NULL AND item_sent = 1 +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + RETURNING chat_item_id + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? AND item_deleted = ? + RETURNING chat_item_id; + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_member_intros + SET intro_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_intro_id = ? + +Plan: +SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM chat_items + WHERE group_scope_group_member_id = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: + DELETE FROM chat_items + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: + DELETE FROM chat_items + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: + DELETE FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: + DELETE FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH c USING COVERING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH c USING COVERING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM contact_profiles + WHERE + user_id = ? + AND (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_members) + OR contact_profile_id IN (SELECT member_profile_id FROM temp_delete_members WHERE member_profile_id IS NOT NULL)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections) + +Plan: +SEARCH contact_profiles USING COVERING INDEX idx_contact_profiles_user_id (user_id=?) +LIST SUBQUERY 1 +SCAN temp_delete_members +LIST SUBQUERY 2 +SCAN temp_delete_members +USING INDEX idx_group_members_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_group_members_member_profile_id FOR IN-OPERATOR +USING INDEX idx_contacts_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_contact_requests_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_connections_custom_user_profile_id FOR IN-OPERATOR +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT contact_profile_id + FROM contacts + WHERE user_id = ? AND contact_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT cr.contact_profile_id + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT cr.contact_profile_id + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? + AND 1 NOT IN ( + SELECT 1 FROM connections + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contacts + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? + AND (member_profile_id = ? OR contact_profile_id = ?) + LIMIT 1 + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH connections USING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +LIST SUBQUERY 2 +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_2 (user_id=? AND contact_profile_id=?) +LIST SUBQUERY 3 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=? AND contact_profile_id=?) +LIST SUBQUERY 4 +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 + AND 1 NOT IN ( + SELECT 1 FROM connections + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? AND member_profile_id = ? LIMIT 1 + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH connections USING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +LIST SUBQUERY 2 +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM display_names + WHERE + user_id = ? + AND local_display_name IN (SELECT local_display_name FROM temp_delete_members) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SCAN temp_delete_members +LIST SUBQUERY 2 +SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name +LIST SUBQUERY 3 +SCAN contacts USING COVERING INDEX sqlite_autoindex_contacts_1 +USING INDEX sqlite_autoindex_users_2 FOR IN-OPERATOR +LIST SUBQUERY 5 +SCAN groups USING COVERING INDEX sqlite_autoindex_groups_1 +LIST SUBQUERY 6 +SCAN user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 +LIST SUBQUERY 7 +SCAN contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? + AND local_display_name in ( + SELECT cr.local_display_name + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? + AND local_display_name in ( + SELECT cr.local_display_name + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ( + SELECT local_display_name FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +SCALAR SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND 1 NOT IN ( + SELECT 1 FROM users + WHERE local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contacts + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM groups + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM user_contact_links + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +LIST SUBQUERY 2 +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 3 +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 4 +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +LIST SUBQUERY 5 +SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 6 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 7 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM files + WHERE user_id = ? + AND chat_item_id IN ( + SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_id = ? + ) + +Plan: +SEARCH files USING INDEX idx_files_user_id (user_id=?) +LIST SUBQUERY 1 +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) +SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) + +Query: + DELETE FROM group_profiles + WHERE group_profile_id in ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SEARCH groups USING COVERING INDEX idx_groups_group_profile_id (group_profile_id=?) + +Query: + INSERT INTO calls + (contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_moderations + (group_id, moderator_member_id, item_member_id, shared_msg_id, created_by_msg_id, moderated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_versions (chat_item_id, msg_content, item_version_ts) + VALUES (?,?,?) + +Plan: + +Query: + INSERT INTO chat_items ( + -- user and IDs + user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, + -- meta + item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, + -- quote + quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, + -- forwarded from + fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO commands (connection_id, command_function, command_status, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections + (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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, + contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) + +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + +Query: + INSERT INTO files + ( user_id, note_folder_id, + file_name, file_path, file_size, + file_crypto_key, file_crypto_nonce, + chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO msg_deliveries + (message_id, connection_id, agent_msg_id, chat_ts, created_at, updated_at, delivery_status) + VALUES (?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO pending_group_messages + (group_member_id, message_id, group_member_intro_id, created_at, updated_at) VALUES (?,?,?,?,?) + +Plan: + +Query: + INSERT INTO protocol_servers + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO remote_controllers + (ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key) + VALUES + (?, ?, ?, ?, ?, ?, ?) + +Plan: + +Query: + INSERT INTO temp_delete_members (contact_profile_id, member_profile_id, local_display_name) + SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? + +Plan: +SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=?) + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) + +Query: + SELECT group_snd_item_status, COUNT(1) + FROM group_snd_item_statuses + WHERE chat_item_id = ? + GROUP BY group_snd_item_status + +Plan: +SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT s.file_id, r.file_id, f.protocol + FROM files f + LEFT JOIN snd_files s ON s.file_id = f.file_id + LEFT JOIN rcv_files r ON r.file_id = f.file_id + WHERE user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING COVERING INDEX idx_snd_files_file_id (file_id=?) LEFT-JOIN +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, updated_at = ? + WHERE user_id = ? AND note_folder_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + UPDATE commands + SET connection_id = ?, updated_at = ? + WHERE user_id = ? AND command_id = ? + +Plan: +SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET conn_chat_version = ?, peer_chat_min_version = ?, peer_chat_max_version = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET conn_req_inv = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET contact_conn_initiated = 0, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET custom_user_profile_id = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET local_alias = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_rcv_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_snd_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_snd_enabled = ?, pq_rcv_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_support = ?, pq_encryption = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET user_id = ?, custom_user_profile_id = NULL, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET contact_link = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, contact_link = ?, preferences = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET local_alias = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET contact_group_member_id = NULL, contact_grp_inv_sent = 0, updated_at = ? + WHERE contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET contact_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_member_intros + SET intro_status = ?, updated_at = ? + WHERE group_member_intro_id = ? + +Plan: +SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_restriction = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_status = ?, member_role = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_status = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET show_messages = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = NULL, + support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0, + support_chat_last_msg_from_member_ts = NULL, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_profiles + SET preferences = ?, updated_at = ? + WHERE group_profile_id IN ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_snd_item_statuses + SET group_snd_item_status = ?, updated_at = ? + WHERE chat_item_id = ? AND group_member_id = ? + +Plan: +SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) + +Query: + UPDATE group_snd_item_statuses + SET via_proxy = ? + WHERE chat_item_id = ? AND group_member_id = ? + +Plan: +SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) + +Query: + UPDATE groups + SET members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 1 +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE msg_deliveries + SET delivery_status = ?, updated_at = ? + WHERE connection_id = ? AND agent_msg_id = ? + +Plan: +SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) + +Query: + UPDATE protocol_servers + SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, + preset = ?, tested = ?, enabled = ?, updated_at = ? + WHERE smp_server_id = ? + +Plan: +SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE rcv_files + SET to_receive = 1, user_approved_relays = ?, updated_at = ? + WHERE file_id = ? + +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE remote_controllers + SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key + WHERE remote_ctrl_id = ? + +Plan: +SEARCH remote_controllers USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE remote_hosts + SET host_device_name = ?, host_dh_pub = ?, bind_addr = ?, bind_iface = ?, bind_port = ? + WHERE remote_host_id = ? + +Plan: +SEARCH remote_hosts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE server_operators + SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? + WHERE server_operator_id = ? + +Plan: +SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE users + SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ? + WHERE user_id = ? + +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE xftp_file_descriptions + SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ?, updated_at = ? + WHERE user_id = ? AND file_descr_id = ? + +Plan: +SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM messages + WHERE message_id IN ( + SELECT message_id + FROM chat_item_messages + WHERE chat_item_id = ? + ) + +Plan: +SEARCH messages USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_message_id (message_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) +SEARCH chat_items USING COVERING INDEX sqlite_autoindex_chat_items_1 (created_by_msg_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_message_id (message_id=?) + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ? +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + + WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status IN (?, ?, ?, ?) + AND m.group_member_id NOT IN ( + SELECT DISTINCT group_member_id FROM chat_items + ) + +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +LIST SUBQUERY 2 +SCAN chat_items USING COVERING INDEX idx_chat_items_group_member_id +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +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 + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +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 + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_id = ? AND m.member_id = ? +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +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 + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +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 + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +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 + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +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 + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.created_at <= ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at ? ) OR ( user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_ts = ? AND chat_item_id > ? )) ORDER BY item_ts ASC, chat_item_id ASC LIMIT ? +Plan: +MULTI-INDEX OR +INDEX 1 +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_ts>?) +INDEX 2 +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_ts=? AND rowid>?) +USE TEMP B-TREE FOR ORDER BY + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag = ? AND group_scope_group_member_id = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag = ? AND group_scope_group_member_id IS NULL ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_item_ts (user_id=? AND group_id=? AND msg_content_tag=?) + +Query: CREATE TABLE temp_conn_ids (conn_id BLOB) +Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_conn_ids (conn_id BLOB)": table temp_conn_ids already exists + +Query: CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT) +Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)": table temp_delete_members already exists + +Query: DELETE FROM app_settings +Plan: + +Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_mentions WHERE chat_item_id = ? +Plan: +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) + +Query: DELETE FROM chat_item_moderations WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? +Plan: +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND created_at <= ? +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_reactions WHERE group_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) + +Query: DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ? +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_versions WHERE chat_item_id = ? +Plan: +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) + +Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at <= ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at 0 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) + +Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? +Plan: +SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) + +Query: SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1 +Plan: +SEARCH connections USING INDEX idx_connections_conn_req_inv (user_id=? AND conn_req_inv=?) + +Query: SELECT agent_conn_id FROM connections where to_subscribe = 1 +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) + +Query: SELECT app_settings FROM app_settings +Plan: +SCAN app_settings + +Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1 +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? LIMIT 1 +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id IS NULL +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1 +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1 +Plan: +SEARCH settings USING INDEX idx_settings_user_id (user_id=?) + +Query: SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ? +Plan: +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) + +Query: SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ? +Plan: +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_group_id (group_id=?) + +Query: SELECT conn_req_inv FROM connections WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT conn_status FROM connections WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT connection_id FROM connections WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) + +Query: SELECT connection_id FROM connections WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH connections USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Plan: +SCAN contacts + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0 +Plan: +SEARCH contacts USING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) + +Query: SELECT contact_id FROM group_members WHERE contact_id = ? LIMIT 1 +Plan: +SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) + +Query: SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) + +Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?) + +Query: SELECT count(1) FROM group_members +Plan: +SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id + +Query: SELECT count(1) FROM pending_group_messages +Plan: +SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id + +Query: SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ? +Plan: +SEARCH files USING INDEX idx_files_redirect_file_id (redirect_file_id=?) + +Query: SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ? +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_id FROM group_members WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1 +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: SELECT group_id FROM groups WHERE user_id = ? +Plan: +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_2 (user_id=?) + +Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Plan: +SCAN groups + +Query: SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) + +Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL +Plan: +SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1 +Plan: +SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) + +Query: SELECT last_insert_rowid() +Plan: +SCAN CONSTANT ROW + +Query: SELECT max(active_order) FROM users +Plan: +SEARCH users + +Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? +Plan: +SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) + +Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1 +Plan: +SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) + +Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) + +Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ? +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_id, 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=?) + +Query: SELECT user_id FROM users WHERE local_display_name = ? +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) + +Query: SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1 +Plan: +SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) + +Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_user_id_item_status (user_id=? AND item_status=?) + +Query: UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET auth_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +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=?) + +Query: UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3 +Plan: +SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id=?) + +Query: UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET security_code = ?, security_code_verified_at = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1 +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) + +Query: UPDATE contact_requests SET contact_id = ? WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH contact_requests USING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) + +Query: UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET chat_item_ttl = ?, updated_at = ? WHERE contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_used = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET send_rcpts = NULL +Plan: +SCAN contacts + +Query: UPDATE contacts SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET agent_snd_file_deleted = 1, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET cancelled = 1, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET file_crypto_key = ?, file_crypto_nonce = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_member_intros SET intro_status='con' +Plan: +SCAN group_member_intros + +Query: UPDATE group_member_intros SET intro_status='fwd' +Plan: +SCAN group_member_intros + +Query: UPDATE group_members SET contact_id = ?, updated_at = ? WHERE contact_profile_id = ? +Plan: +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) + +Query: UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET chat_item_id = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET send_rcpts = NULL +Plan: +SCAN groups + +Query: UPDATE groups SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: UPDATE groups SET user_member_profile_sent_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET chat_ts = ? WHERE user_id = ? AND note_folder_id = ? +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ? +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH note_folders USING INDEX note_folders_user_id (user_id=?) + +Query: UPDATE rcv_files SET agent_rcv_file_deleted = 1, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET agent_rcv_file_id = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET user_approved_relays = ?, rcv_file_inline = ?, file_status = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE settings SET chat_item_ttl = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH settings USING INDEX idx_settings_user_id (user_id=?) + +Query: UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ? +Plan: +SEARCH snd_files USING PRIMARY KEY (file_id=? AND connection_id=?) + +Query: UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_descr_id = ? +Plan: +SEARCH snd_files USING PRIMARY KEY (file_id=? AND connection_id=?) + +Query: UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ? +Plan: +SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET active_user = 0 +Plan: +SCAN users + +Query: UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET contact_id = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL +Plan: +SCAN users + +Query: UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ? +Plan: +SEARCH users 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 923928ad5c..91670ffc7b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -108,7 +108,8 @@ CREATE TABLE group_profiles( image TEXT, user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE, preferences TEXT, - description TEXT NULL + description TEXT NULL, + member_admission TEXT ); CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID @@ -120,7 +121,6 @@ CREATE TABLE groups( updated_at TEXT CHECK(updated_at NOT NULL), chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL, enable_ntfs INTEGER, - host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL), chat_ts TEXT, favorite INTEGER NOT NULL DEFAULT 0, @@ -134,7 +134,8 @@ CREATE TABLE groups( business_xcontact_id BLOB NULL, customer_member_id BLOB NULL, chat_item_ttl INTEGER, - local_alias TEXT DEFAULT '', -- received + local_alias TEXT DEFAULT '', + members_require_attention INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -167,6 +168,11 @@ CREATE TABLE group_members( peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, member_restriction TEXT, + support_chat_ts TEXT, + support_chat_items_unread INTEGER NOT NULL DEFAULT 0, + support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0, + support_chat_items_mentions INTEGER NOT NULL DEFAULT 0, + support_chat_last_msg_from_member_ts TEXT, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -298,6 +304,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 @@ -317,6 +325,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( @@ -406,7 +415,11 @@ CREATE TABLE chat_items( fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, via_proxy INTEGER, - msg_content_tag TEXT + msg_content_tag TEXT, + include_in_history INTEGER NOT NULL DEFAULT 0, + user_mention INTEGER NOT NULL DEFAULT 0, + group_scope_tag TEXT, + group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -641,6 +654,13 @@ CREATE TABLE chat_tags_chats( group_id INTEGER REFERENCES groups ON DELETE CASCADE, chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE ); +CREATE TABLE chat_item_mentions( + chat_item_mention_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BLOB NOT NULL, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + display_name TEXT NOT NULL +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -649,7 +669,6 @@ CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); -CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( user_id, contact_id, @@ -722,9 +741,6 @@ CREATE INDEX idx_group_members_contact_profile_id ON group_members( CREATE INDEX idx_group_members_user_id ON group_members(user_id); CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); -CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( - host_conn_custom_user_profile_id -); CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); CREATE INDEX idx_messages_group_id ON messages(group_id); @@ -978,3 +994,57 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( item_deleted, item_sent ); +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses( + chat_item_id, + group_member_id +); +CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions( + chat_item_id +); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions( + chat_item_id, + display_name +); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions( + chat_item_id, + member_id +); +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( + user_id, + group_id, + item_status, + user_mention +); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( + group_id, + shared_msg_id +); +CREATE INDEX idx_chat_items_group_scope_group_member_id ON chat_items( + group_scope_group_member_id +); +CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_ts +); +CREATE INDEX idx_chat_items_group_scope_item_status ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_status, + item_ts +); diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index fab4c344bf..8c9c50e9de 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 @@ -140,6 +141,8 @@ data StoreError | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} | SEOperatorNotFound {serverOperatorId :: Int64} | SEUsageConditionsNotFound + | SEInvalidQuote + | SEInvalidMention deriving (Show, Exception) $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) @@ -379,10 +382,10 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () createContact db User {userId} profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db userId profile "" Nothing currentTs True + void $ createContact_ db userId profile "" Nothing currentTs -createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> Bool -> ExceptT StoreError IO (Text, ContactId, ProfileId) -createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs contactUsed = +createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId) +createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -392,7 +395,7 @@ createContact_ db userId Profile {displayName, fullName, image, contactLink, pre DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used) VALUES (?,?,?,?,?,?,?,?)" - (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI contactUsed) + (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI True) contactId <- insertedRowId db pure $ Right (ldn, contactId, profileId) @@ -414,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' @@ -439,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 @@ -460,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 @@ -468,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 = @@ -575,27 +579,37 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow +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, Int) :. 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)) +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, Maybe UTCTime) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = +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, membersRequireAttention) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences - groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} + groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + supportChat = case supportChatTs_ of + Just chatTs -> + Just GroupSupportChat { + chatTs, + unread = supportChatUnread, + memberAttention = supportChatMemberAttention, + mentions = supportChatMentions, + lastMsgFromMemberTs = supportChatLastMsgFromMemberTs + } + _ -> Nothing in GroupMember {..} toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo @@ -608,12 +622,15 @@ groupInfoQuery = SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 829bdc3d31..e432343839 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -9,14 +9,14 @@ 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.Options.DB import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) @@ -29,6 +29,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB +import Simplex.Chat.Options.DB import System.IO (hFlush, hSetEcho, stdin, stdout) #endif diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index bf48d1d4f5..7c52f59a50 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -64,12 +64,14 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand rh' bs) cc - processResp s cmd rh r + case r of + Right r' -> processResp cmd rh r' + Left _ -> when (isMessage cmd) $ echo s printRespToTerminal ct cc False rh r - startLiveMessage cmd r + mapM_ (startLiveMessage cmd) r where echo s = printToTerminal ct [plain s] - processResp s cmd rh = \case + processResp cmd rh = \case CRActiveUser u -> case rh of Nothing -> setActive ct "" Just rhId -> updateRemoteUser ct u rhId @@ -80,8 +82,6 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g - CRChatCmdError _ _ -> when (isMessage cmd) $ echo s - CRChatError _ _ -> when (isMessage cmd) $ echo s CRCmdOk _ -> case cmd of Right APIDeleteUser {} -> setActive ct "" _ -> pure () @@ -133,7 +133,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do updateLiveMessage typedMsg lm = case liveMessageToSend typedMsg lm of Just sentMsg -> sendUpdatedLiveMessage cc sentMsg lm True >>= \case - CRChatItemUpdated {} -> setLiveMessage lm {sentMsg, typedMsg} + Right CRChatItemUpdated {} -> setLiveMessage lm {sentMsg, typedMsg} _ -> do -- TODO print error setLiveMessage lm {typedMsg} @@ -147,10 +147,10 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do | otherwise = (s <> reverse (c : w), "") startLiveMessage _ _ = pure () -sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO ChatResponse +sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO (Either ChatError ChatResponse) sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg - either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) `runReaderT` cc + runExceptT (processChatCommand cmd) `runReaderT` cc runTerminalInput :: ChatTerminal -> ChatController -> IO () runTerminalInput ct cc = withChatTerm ct $ do diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index aa9adb059f..38b0e91a8a 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} module Simplex.Chat.Terminal.Main where @@ -7,15 +8,13 @@ import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.STM import Control.Monad import Data.Maybe (fromMaybe) -import Data.Time.Clock (getCurrentTime) -import Data.Time.LocalTime (getCurrentTimeZone) import Network.Socket -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError, ChatEvent (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Options.DB import Simplex.Chat.Terminal -import Simplex.Chat.View (serializeChatResponse, smpProxyModeStr) +import Simplex.Chat.View (ChatResponseEvent, smpProxyModeStr) import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (..)) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) @@ -43,18 +42,17 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer simplexChatTerminal cfg opts t runCommand user cc = do when (chatCmdLog /= CCLNone) . void . forkIO . forever $ do - (_, _, r') <- atomically . readTBQueue $ outputQ cc - case r' of - CRNewChatItems {} -> printResponse r' - _ -> when (chatCmdLog == CCLAll) $ printResponse r' + (_, r) <- atomically . readTBQueue $ outputQ cc + case r of + Right CEvtNewChatItems {} -> printResponse r + _ -> when (chatCmdLog == CCLAll) $ printResponse r sendChatCmdStr cc chatCmd >>= printResponse threadDelay $ chatCmdDelay * 1000000 where + printResponse :: ChatResponseEvent r => Either ChatError r -> IO () printResponse r = do - ts <- getCurrentTime - tz <- getCurrentTimeZone rh <- readTVarIO $ currentRemoteHost cc - putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r + printResponseEvent (rh, Just user) cfg r welcome :: ChatConfig -> ChatOpts -> IO () welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbOptions, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 6c914c7c6d..1ec09d43d6 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -7,6 +7,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Terminal.Output where @@ -146,22 +147,22 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> ChatOpts -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} ChatOpts {markRead} = do forever $ do - (_, outputRH, r) <- atomically $ readTBQueue outputQ - case r of - CRNewChatItems u (ci : _) -> when markRead $ markChatItemRead u ci -- At the moment of writing received items are created one at a time - CRChatItemUpdated u ci -> when markRead $ markChatItemRead u ci - CRRemoteHostConnected {remoteHost = RemoteHostInfo {remoteHostId}} -> getRemoteUser remoteHostId - CRRemoteHostStopped {remoteHostId_} -> mapM_ removeRemoteUser remoteHostId_ + (outputRH, r_) <- atomically $ readTBQueue outputQ + forM_ r_ $ \case + CEvtNewChatItems u (ci : _) -> when markRead $ markChatItemRead u ci -- At the moment of writing received items are created one at a time + CEvtChatItemUpdated u ci -> when markRead $ markChatItemRead u ci + CEvtRemoteHostConnected {remoteHost = RemoteHostInfo {remoteHostId}} -> getRemoteUser remoteHostId + CEvtRemoteHostStopped {remoteHostId_} -> mapM_ removeRemoteUser remoteHostId_ _ -> pure () - let printResp = case logFilePath of - Just path -> if logResponseToFile r then logResponse path else printToTerminal ct + let printEvent = case logFilePath of + Just path -> if either (const True) logEventToFile r_ then logResponse path else printToTerminal ct _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems - responseString ct cc liveItems outputRH r >>= printResp - responseNotification ct cc r + responseString ct cc liveItems outputRH r_ >>= printEvent + mapM_ (chatEventNotification ct cc) r_ where markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = - case (chatDirNtf u chat chatDir (isMention ci), itemStatus) of + case (chatDirNtf u chat chatDir (isUserMention ci), itemStatus) of (True, CISRcvNew) -> do let itemId = chatItemId' ci chatRef = chatInfoToRef chat @@ -170,45 +171,47 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s getRemoteUser rhId = runReaderT (execChatCommand (Just rhId) "/user") cc >>= \case - CRActiveUser {user} -> updateRemoteUser ct user rhId + Right CRActiveUser {user} -> updateRemoteUser ct user rhId cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) -responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () -responseNotification t@ChatTerminal {sendNotification} cc = \case +chatEventNotification :: ChatTerminal -> ChatController -> ChatEvent -> IO () +chatEventNotification t@ChatTerminal {sendNotification} cc = \case -- At the moment of writing received items are created one at a time - CRNewChatItems u ((AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) : _) -> - when (chatDirNtf u cInfo chatDir $ isMention ci) $ do + CEvtNewChatItems u ((AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) : _) -> + when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) - (GroupChat g, CIGroupRcv m) -> sendNtf (fromGroup_ g m, text) + (GroupChat g scopeInfo, CIGroupRcv m) -> sendNtf (fromGroup_ g scopeInfo m, text) _ -> pure () where text = msgText mc formattedText - CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> - whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isMention ci) $ setActiveChat t cInfo - CRContactConnected u ct _ -> when (contactNtf u ct False) $ do + CEvtChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ setActiveChat t cInfo + CEvtContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") - CRContactSndReady u ct -> + CEvtContactSndReady u ct -> whenCurrUser cc u $ setActiveContact t ct - CRContactAnotherClient u ct -> do + CEvtContactAnotherClient u ct -> do whenCurrUser cc u $ unsetActiveContact t ct when (contactNtf u ct False) $ sendNtf (viewContactName ct <> "> ", "connected to another client") - CRContactsDisconnected srv _ -> serverNtf srv "disconnected" - CRContactsSubscribed srv _ -> serverNtf srv "connected" - CRReceivedGroupInvitation u g ct _ _ -> + CEvtContactsDisconnected srv _ -> serverNtf srv "disconnected" + CEvtContactsSubscribed srv _ -> serverNtf srv "connected" + CEvtReceivedGroupInvitation u g ct _ _ -> when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") - CRUserJoinedGroup u g _ -> when (groupNtf u g False) $ do + CEvtUserJoinedGroup u g _ -> when (groupNtf u g False) $ do whenCurrUser cc u $ setActiveGroup t g sendNtf ("#" <> viewGroupName g, "you are connected to group") - CRJoinedGroupMember u g m -> + CEvtJoinedGroupMember u g m -> when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") - CRConnectedToGroupMember u g m _ -> + CEvtJoinedGroupMemberConnecting u g _ m | memberStatus m == GSMemPendingReview -> + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is pending review") + CEvtConnectedToGroupMember u g m _ -> when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") - CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = n} -> when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") _ -> pure () where @@ -232,7 +235,7 @@ chatActiveTo (ChatName cType name) = case cType of chatInfoActiveTo :: ChatInfo c -> String chatInfoActiveTo = \case DirectChat c -> contactActiveTo c - GroupChat g -> groupActiveTo g + GroupChat g _scopeInfo -> groupActiveTo g _ -> "" contactActiveTo :: Contact -> String @@ -271,15 +274,17 @@ whenCurrUser cc u a = do where sameUser User {userId = uId} = maybe False $ \User {userId} -> userId == uId -printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO () +printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> Either ChatError ChatResponse -> IO () printRespToTerminal ct cc liveItems outputRH r = responseString ct cc liveItems outputRH r >>= printToTerminal ct -responseString :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO [StyledString] -responseString ct cc liveItems outputRH r = do - cu <- getCurrentUser ct cc - ts <- getCurrentTime - tz <- getCurrentTimeZone - pure $ responseToView cu (config cc) liveItems ts tz outputRH r +responseString :: forall r. ChatResponseEvent r => ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> Either ChatError r -> IO [StyledString] +responseString ct cc liveItems outputRH = \case + Right r -> do + cu <- getCurrentUser ct cc + ts <- getCurrentTime + tz <- getCurrentTimeZone + pure $ responseToView cu (config cc) liveItems ts tz outputRH r + Left e -> pure $ chatErrorToView (isCommandResponse @r) (config cc) e updateRemoteUser :: ChatTerminal -> User -> RemoteHostId -> IO () updateRemoteUser ct user rhId = atomically $ TM.insert rhId user (currentRemoteUsers ct) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 11587694cb..567baf0cdf 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -26,14 +26,17 @@ module Simplex.Chat.Types where +import Control.Applicative ((<|>)) import Crypto.Number.Serialize (os2ip) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString, pack, unpack) import qualified Data.ByteString.Lazy as LB +import Data.Functor (($>)) import Data.Int (Int64) import Data.Maybe (isJust) import Data.Text (Text) @@ -48,13 +51,13 @@ 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.Store.DB (Binary (..)) +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) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) -import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal #if defined(dbPostgres) @@ -217,6 +220,8 @@ contactConnId c = aConnId <$> contactConn c type IncognitoEnabled = Bool +type CreateShortLink = Bool + contactConnIncognito :: Contact -> IncognitoEnabled contactConnIncognito = maybe False connIncognito . contactConn @@ -366,6 +371,8 @@ type UserName = Text type ContactName = Text +type MemberName = Text + type GroupName = Text optionalFullName :: ContactName -> Text -> Text @@ -373,6 +380,26 @@ optionalFullName displayName fullName | T.null fullName || displayName == fullName = "" | otherwise = " (" <> fullName <> ")" +data ShortGroup = ShortGroup + { shortInfo :: ShortGroupInfo, + members :: [ShortGroupMember] + } + +data ShortGroupInfo = ShortGroupInfo + { groupId :: GroupId, + groupName :: GroupName, + membershipStatus :: GroupMemberStatus + } + deriving (Eq, Show) + +data ShortGroupMember = ShortGroupMember + { groupMemberId :: GroupMemberId, + groupId :: GroupId, + memberName :: ContactName, + connId :: AgentConnId + } + deriving (Show) + data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Eq, Show) @@ -386,7 +413,6 @@ data GroupInfo = GroupInfo businessChat :: Maybe BusinessChatInfo, fullGroupPreferences :: FullGroupPreferences, membership :: GroupMember, - hostConnCustomUserProfileId :: Maybe ProfileId, chatSettings :: ChatSettings, createdAt :: UTCTime, updatedAt :: UTCTime, @@ -395,7 +421,8 @@ data GroupInfo = GroupInfo chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, - customData :: Maybe CustomData + customData :: Maybe CustomData, + membersRequireAttention :: Int } deriving (Eq, Show) @@ -535,7 +562,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 @@ -568,7 +595,7 @@ data LocalProfile = LocalProfile displayName :: ContactName, fullName :: Text, image :: Maybe ImageData, - contactLink :: Maybe ConnReqContact, + contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, localAlias :: LocalAlias } @@ -590,10 +617,24 @@ data GroupProfile = GroupProfile fullName :: Text, description :: Maybe Text, image :: Maybe ImageData, - groupPreferences :: Maybe GroupPreferences + groupPreferences :: Maybe GroupPreferences, + memberAdmission :: Maybe GroupMemberAdmission } deriving (Eq, Show) +data GroupMemberAdmission = GroupMemberAdmission + { -- names :: Maybe MemberCriteria, + -- captcha :: Maybe MemberCriteria, + review :: Maybe MemberCriteria + } + deriving (Eq, Show) + +data MemberCriteria = MCAll + deriving (Eq, Show) + +emptyGroupMemberAdmission :: GroupMemberAdmission +emptyGroupMemberAdmission = GroupMemberAdmission Nothing + newtype ImageData = ImageData Text deriving (Eq, Show) @@ -644,11 +685,47 @@ data GroupLinkInvitation = GroupLinkInvitation fromMemberName :: ContactName, invitedMember :: MemberIdRole, groupProfile :: GroupProfile, + accepted :: Maybe GroupAcceptance, business :: Maybe BusinessChatInfo, groupSize :: Maybe Int } deriving (Eq, Show) +data GroupLinkRejection = GroupLinkRejection + { fromMember :: MemberIdRole, + invitedMember :: MemberIdRole, + groupProfile :: GroupProfile, + rejectionReason :: GroupRejectionReason + } + deriving (Eq, Show) + +data GroupRejectionReason + = GRRLongName + | GRRBlockedName + | GRRUnknown {text :: Text} + deriving (Eq, Show) + +instance FromField GroupRejectionReason where fromField = blobFieldDecoder strDecode + +instance ToField GroupRejectionReason where toField = toField . strEncode + +instance StrEncoding GroupRejectionReason where + strEncode = \case + GRRLongName -> "long_name" + GRRBlockedName -> "blocked_name" + GRRUnknown text -> encodeUtf8 text + strP = + "long_name" $> GRRLongName + <|> "blocked_name" $> GRRBlockedName + <|> GRRUnknown . safeDecodeUtf8 <$> A.takeByteString + +instance FromJSON GroupRejectionReason where + parseJSON = strParseJSON "GroupRejectionReason" + +instance ToJSON GroupRejectionReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole @@ -763,10 +840,29 @@ data GroupMember = GroupMember -- member chat protocol version range; if member has active connection, its version range is preferred; -- for membership current supportedChatVRange is set, it's not updated on protocol version increase in database, -- but it's correctly set on read (see toGroupInfo) - memberChatVRange :: VersionRangeChat + memberChatVRange :: VersionRangeChat, + createdAt :: UTCTime, + updatedAt :: UTCTime, + supportChat :: Maybe GroupSupportChat } deriving (Eq, Show) +data GroupSupportChat = GroupSupportChat + { chatTs :: UTCTime, + unread :: Int64, + memberAttention :: Int64, + mentions :: Int64, + lastMsgFromMemberTs :: Maybe UTCTime + } + deriving (Eq, Show) + +gmRequiresAttention :: GroupMember -> Bool +gmRequiresAttention m@GroupMember {supportChat} = + memberPending m || maybe False supportChatAttention supportChat + where + supportChatAttention GroupSupportChat {memberAttention, mentions} = + memberAttention > 0 || mentions > 0 + data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile} deriving (Eq, Show) @@ -780,6 +876,9 @@ memberConn GroupMember {activeConn} = activeConn memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn +sameMemberId :: MemberId -> GroupMember -> Bool +sameMemberId memId GroupMember {memberId} = memId == memberId + memberChatVRange' :: GroupMember -> VersionRangeChat memberChatVRange' GroupMember {activeConn, memberChatVRange} = case activeConn of Just Connection {peerChatVRange} -> peerChatVRange @@ -791,6 +890,9 @@ supportsVersion m v = maxVersion (memberChatVRange' m) >= v groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId +memberId' :: GroupMember -> MemberId +memberId' GroupMember {memberId} = memberId + memberIncognito :: GroupMember -> IncognitoEnabled memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId @@ -819,7 +921,7 @@ data NewGroupMember = NewGroupMember } newtype MemberId = MemberId {unMemberId :: ByteString} - deriving (Eq, Show) + deriving (Eq, Ord, Show) deriving newtype (FromField) instance ToField MemberId where toField (MemberId m) = toField $ Binary m @@ -836,6 +938,9 @@ instance ToJSON MemberId where toJSON = strToJSON toEncoding = strToJEncoding +nameFromMemberId :: MemberId -> ContactName +nameFromMemberId = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId + data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show) @@ -924,11 +1029,14 @@ instance TextEncoding GroupMemberCategory where GCPostMember -> "post" data GroupMemberStatus - = GSMemRemoved -- member who was removed from the group + = GSMemRejected -- joining member who was rejected by the host, or host that rejected the join + | GSMemRemoved -- member who was removed from the group | GSMemLeft -- member who left the group | GSMemGroupDeleted -- user member of the deleted group | GSMemUnknown -- unknown member, whose message was forwarded by an admin (likely member wasn't introduced due to not being a current member, but message was included in history) | GSMemInvited -- member is sent to or received invitation to join the group + | GSMemPendingApproval -- member is connected to host but pending host approval before connecting to other members ("knocking") + | GSMemPendingReview -- member is introduced to admins but pending admin review before connecting to other members ("knocking") | GSMemIntroduced -- user received x.grp.mem.intro for this member (only with GCPreMember) | GSMemIntroInvited -- member is sent to or received from intro invitation | GSMemAccepted -- member accepted invitation (only User and Invitee) @@ -949,13 +1057,23 @@ instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode +acceptanceToStatus :: Maybe GroupMemberAdmission -> GroupAcceptance -> GroupMemberStatus +acceptanceToStatus memberAdmission groupAcceptance + | groupAcceptance == GAPendingApproval = GSMemPendingApproval + | groupAcceptance == GAPendingReview = GSMemPendingReview + | (memberAdmission >>= review) == Just MCAll = GSMemPendingReview + | otherwise = GSMemAccepted + memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of + GSMemRejected -> False GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False GSMemUnknown -> False GSMemInvited -> False + GSMemPendingApproval -> True + GSMemPendingReview -> True GSMemIntroduced -> False GSMemIntroInvited -> False GSMemAccepted -> False @@ -967,14 +1085,26 @@ memberActive m = case memberStatus m of memberCurrent :: GroupMember -> Bool memberCurrent = memberCurrent' . memberStatus +memberPending :: GroupMember -> Bool +memberPending m = case memberStatus m of + GSMemPendingApproval -> True + GSMemPendingReview -> True + _ -> False + +memberCurrentOrPending :: GroupMember -> Bool +memberCurrentOrPending m = memberCurrent m || memberPending m + -- update getGroupSummary if this is changed memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case + GSMemRejected -> False GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False GSMemUnknown -> False GSMemInvited -> False + GSMemPendingApproval -> False + GSMemPendingReview -> False GSMemIntroduced -> True GSMemIntroInvited -> True GSMemAccepted -> True @@ -985,11 +1115,14 @@ memberCurrent' = \case memberRemoved :: GroupMember -> Bool memberRemoved m = case memberStatus m of + GSMemRejected -> True GSMemRemoved -> True GSMemLeft -> True GSMemGroupDeleted -> True GSMemUnknown -> False GSMemInvited -> False + GSMemPendingApproval -> False + GSMemPendingReview -> False GSMemIntroduced -> False GSMemIntroInvited -> False GSMemAccepted -> False @@ -1000,11 +1133,14 @@ memberRemoved m = case memberStatus m of instance TextEncoding GroupMemberStatus where textDecode = \case + "rejected" -> Just GSMemRejected "removed" -> Just GSMemRemoved "left" -> Just GSMemLeft "deleted" -> Just GSMemGroupDeleted "unknown" -> Just GSMemUnknown "invited" -> Just GSMemInvited + "pending_approval" -> Just GSMemPendingApproval + "pending_review" -> Just GSMemPendingReview "introduced" -> Just GSMemIntroduced "intro-inv" -> Just GSMemIntroInvited "accepted" -> Just GSMemAccepted @@ -1014,11 +1150,14 @@ instance TextEncoding GroupMemberStatus where "creator" -> Just GSMemCreator _ -> Nothing textEncode = \case + GSMemRejected -> "rejected" GSMemRemoved -> "removed" GSMemLeft -> "left" GSMemGroupDeleted -> "deleted" GSMemUnknown -> "unknown" GSMemInvited -> "invited" + GSMemPendingApproval -> "pending_approval" + GSMemPendingReview -> "pending_review" GSMemIntroduced -> "introduced" GSMemIntroInvited -> "intro-inv" GSMemAccepted -> "accepted" @@ -1322,6 +1461,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, @@ -1399,7 +1546,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 @@ -1721,6 +1868,16 @@ $(JQ.deriveJSON defaultJSON ''LocalProfile) $(JQ.deriveJSON defaultJSON ''UserContactRequest) +$(JQ.deriveJSON (enumJSON $ dropPrefix "MC") {J.tagSingleConstructors = True} ''MemberCriteria) + +$(JQ.deriveJSON defaultJSON ''GroupMemberAdmission) + +instance ToField GroupMemberAdmission where + toField = toField . encodeJSON + +instance FromField GroupMemberAdmission where + fromField = fromTextField_ decodeJSON + $(JQ.deriveJSON defaultJSON ''GroupProfile) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "IB") ''InvitedBy) @@ -1737,6 +1894,8 @@ $(JQ.deriveJSON defaultJSON ''Connection) $(JQ.deriveJSON defaultJSON ''PendingContactConnection) +$(JQ.deriveJSON defaultJSON ''GroupSupportChat) + $(JQ.deriveJSON defaultJSON ''GroupMember) $(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) @@ -1767,6 +1926,8 @@ $(JQ.deriveJSON defaultJSON ''GroupInvitation) $(JQ.deriveJSON defaultJSON ''GroupLinkInvitation) +$(JQ.deriveJSON defaultJSON ''GroupLinkRejection) + $(JQ.deriveJSON defaultJSON ''IntroInvitation) $(JQ.deriveJSON defaultJSON ''MemberRestrictions) @@ -1812,3 +1973,7 @@ $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) $(JQ.deriveJSON defaultJSON ''ChatTag) + +$(JQ.deriveJSON defaultJSON ''ShortGroupInfo) + +$(JQ.deriveJSON defaultJSON ''ShortGroupMember) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 07e32e7d56..63d80657dc 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -32,8 +32,9 @@ import qualified Data.Text as T import GHC.Records.Compat import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Shared +import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) data ChatFeature @@ -149,6 +150,7 @@ data GroupFeature | GFVoice | GFFiles | GFSimplexLinks + | GFReports | GFHistory deriving (Show) @@ -160,6 +162,7 @@ data SGroupFeature (f :: GroupFeature) where SGFVoice :: SGroupFeature 'GFVoice SGFFiles :: SGroupFeature 'GFFiles SGFSimplexLinks :: SGroupFeature 'GFSimplexLinks + SGFReports :: SGroupFeature 'GFReports SGFHistory :: SGroupFeature 'GFHistory deriving instance Show (SGroupFeature f) @@ -185,6 +188,7 @@ groupFeatureNameText = \case GFVoice -> "Voice messages" GFFiles -> "Files and media" GFSimplexLinks -> "SimpleX links" + GFReports -> "Member reports" GFHistory -> "Recent history" groupFeatureNameText' :: SGroupFeature f -> Text @@ -208,11 +212,12 @@ allGroupFeatures = AGF SGFVoice, AGF SGFFiles, AGF SGFSimplexLinks, + AGF SGFReports, AGF SGFHistory ] groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, history} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -220,6 +225,7 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac SGFVoice -> voice SGFFiles -> files SGFSimplexLinks -> simplexLinks + SGFReports -> reports SGFHistory -> history toGroupFeature :: SGroupFeature f -> GroupFeature @@ -231,6 +237,7 @@ toGroupFeature = \case SGFVoice -> GFVoice SGFFiles -> GFFiles SGFSimplexLinks -> GFSimplexLinks + SGFReports -> GFReports SGFHistory -> GFHistory class GroupPreferenceI p where @@ -243,7 +250,7 @@ instance GroupPreferenceI (Maybe GroupPreferences) where getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) instance GroupPreferenceI FullGroupPreferences where - getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, history} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -251,6 +258,7 @@ instance GroupPreferenceI FullGroupPreferences where SGFVoice -> voice SGFFiles -> files SGFSimplexLinks -> simplexLinks + SGFReports -> reports SGFHistory -> history {-# INLINE getGroupPreference #-} @@ -263,6 +271,7 @@ data GroupPreferences = GroupPreferences voice :: Maybe VoiceGroupPreference, files :: Maybe FilesGroupPreference, simplexLinks :: Maybe SimplexLinksGroupPreference, + reports :: Maybe ReportsGroupPreference, history :: Maybe HistoryGroupPreference } deriving (Eq, Show) @@ -296,6 +305,7 @@ setGroupPreference_ f pref prefs = SGFVoice -> prefs {voice = pref} SGFFiles -> prefs {files = pref} SGFSimplexLinks -> prefs {simplexLinks = pref} + SGFReports -> prefs {reports = pref} SGFHistory -> prefs {history = pref} setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences @@ -325,6 +335,7 @@ data FullGroupPreferences = FullGroupPreferences voice :: VoiceGroupPreference, files :: FilesGroupPreference, simplexLinks :: SimplexLinksGroupPreference, + reports :: ReportsGroupPreference, history :: HistoryGroupPreference } deriving (Eq, Show) @@ -377,22 +388,23 @@ defaultGroupPrefs = FullGroupPreferences { timedMessages = TimedMessagesGroupPreference {enable = FEOff, ttl = Just 86400}, directMessages = DirectMessagesGroupPreference {enable = FEOff, role = Nothing}, - fullDelete = FullDeleteGroupPreference {enable = FEOff}, + fullDelete = FullDeleteGroupPreference {enable = FEOff, role = Nothing}, reactions = ReactionsGroupPreference {enable = FEOn}, voice = VoiceGroupPreference {enable = FEOn, role = Nothing}, files = FilesGroupPreference {enable = FEOn, role = Nothing}, simplexLinks = SimplexLinksGroupPreference {enable = FEOn, role = Nothing}, + reports = ReportsGroupPreference {enable = FEOn}, history = HistoryGroupPreference {enable = FEOff} } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice} = defaultBusinessGroupPrefs { timedMessages = Just TimedMessagesGroupPreference {enable = maybe FEOff enableFeature timedMessages, ttl = maybe Nothing prefParam timedMessages}, - fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete}, + fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete, role = Nothing}, reactions = Just ReactionsGroupPreference {enable = maybe FEOn enableFeature reactions}, voice = Just VoiceGroupPreference {enable = maybe FEOff enableFeature voice, role = Nothing} } @@ -407,11 +419,12 @@ defaultBusinessGroupPrefs = GroupPreferences { timedMessages = Just $ TimedMessagesGroupPreference FEOff Nothing, directMessages = Just $ DirectMessagesGroupPreference FEOff Nothing, - fullDelete = Just $ FullDeleteGroupPreference FEOff, + fullDelete = Just $ FullDeleteGroupPreference FEOn (Just GRModerator), reactions = Just $ ReactionsGroupPreference FEOn, voice = Just $ VoiceGroupPreference FEOff Nothing, files = Just $ FilesGroupPreference FEOn Nothing, simplexLinks = Just $ SimplexLinksGroupPreference FEOn Nothing, + reports = Just $ ReportsGroupPreference FEOff, history = Just $ HistoryGroupPreference FEOn } @@ -493,7 +506,7 @@ data DirectMessagesGroupPreference = DirectMessagesGroupPreference deriving (Eq, Show) data FullDeleteGroupPreference = FullDeleteGroupPreference - {enable :: GroupFeatureEnabled} + {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) data ReactionsGroupPreference = ReactionsGroupPreference @@ -512,6 +525,10 @@ data SimplexLinksGroupPreference = SimplexLinksGroupPreference {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) +data ReportsGroupPreference = ReportsGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show) + data HistoryGroupPreference = HistoryGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show) @@ -550,6 +567,9 @@ instance HasField "enable" FilesGroupPreference GroupFeatureEnabled where instance HasField "enable" SimplexLinksGroupPreference GroupFeatureEnabled where hasField p@SimplexLinksGroupPreference {enable} = (\e -> p {enable = e}, enable) +instance HasField "enable" ReportsGroupPreference GroupFeatureEnabled where + hasField p@ReportsGroupPreference {enable} = (\e -> p {enable = e}, enable) + instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where hasField p@HistoryGroupPreference {enable} = (\e -> p {enable = e}, enable) @@ -569,7 +589,7 @@ instance GroupFeatureI 'GFFullDelete where type GroupFeaturePreference 'GFFullDelete = FullDeleteGroupPreference sGroupFeature = SGFFullDelete groupPrefParam _ = Nothing - groupPrefRole _ = Nothing + groupPrefRole FullDeleteGroupPreference {role} = role instance GroupFeatureI 'GFReactions where type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference @@ -595,6 +615,12 @@ instance GroupFeatureI 'GFSimplexLinks where groupPrefParam _ = Nothing groupPrefRole SimplexLinksGroupPreference {role} = role +instance GroupFeatureI 'GFReports where + type GroupFeaturePreference 'GFReports = ReportsGroupPreference + sGroupFeature = SGFReports + groupPrefParam _ = Nothing + groupPrefRole _ = Nothing + instance GroupFeatureI 'GFHistory where type GroupFeaturePreference 'GFHistory = HistoryGroupPreference sGroupFeature = SGFHistory @@ -607,11 +633,16 @@ instance GroupFeatureNoRoleI 'GFFullDelete instance GroupFeatureNoRoleI 'GFReactions +instance GroupFeatureNoRoleI 'GFReports + instance GroupFeatureNoRoleI 'GFHistory instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role) +instance HasField "role" FullDeleteGroupPreference (Maybe GroupMemberRole) where + hasField p@FullDeleteGroupPreference {role} = (\r -> p {role = r}, role) + instance HasField "role" VoiceGroupPreference (Maybe GroupMemberRole) where hasField p@VoiceGroupPreference {role} = (\r -> p {role = r}, role) @@ -623,6 +654,8 @@ instance HasField "role" SimplexLinksGroupPreference (Maybe GroupMemberRole) whe instance GroupFeatureRoleI 'GFDirectMessages +instance GroupFeatureRoleI 'GFFullDelete + instance GroupFeatureRoleI 'GFVoice instance GroupFeatureRoleI 'GFFiles @@ -756,6 +789,7 @@ mergeGroupPreferences groupPreferences = voice = pref SGFVoice, files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, + reports = pref SGFReports, history = pref SGFHistory } where @@ -772,6 +806,7 @@ toGroupPreferences groupPreferences = voice = pref SGFVoice, files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, + reports = pref SGFReports, history = pref SGFHistory } where @@ -880,6 +915,8 @@ $(J.deriveJSON defaultJSON ''FilesGroupPreference) $(J.deriveJSON defaultJSON ''SimplexLinksGroupPreference) +$(J.deriveJSON defaultJSON ''ReportsGroupPreference) + $(J.deriveJSON defaultJSON ''HistoryGroupPreference) $(J.deriveJSON defaultJSON ''GroupPreferences) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index d5c8f48776..60ebe9d033 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -7,8 +7,8 @@ import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (blobFieldDecoder) import Simplex.Messaging.Util ((<$?>)) data GroupMemberRole @@ -48,3 +48,24 @@ instance FromJSON GroupMemberRole where instance ToJSON GroupMemberRole where toJSON = strToJSON toEncoding = strToJEncoding + +data GroupAcceptance = GAAccepted | GAPendingApproval | GAPendingReview deriving (Eq, Show) + +instance StrEncoding GroupAcceptance where + strEncode = \case + GAAccepted -> "accepted" + GAPendingApproval -> "pending" + GAPendingReview -> "pending_review" + strDecode = \case + "accepted" -> Right GAAccepted + "pending" -> Right GAPendingApproval + "pending_review" -> Right GAPendingReview + r -> Left $ "bad GroupAcceptance " <> B.unpack r + strP = strDecode <$?> A.takeByteString + +instance FromJSON GroupAcceptance where + parseJSON = strParseJSON "GroupAcceptance" + +instance ToJSON GroupAcceptance where + toJSON = strToJSON + toEncoding = strToJEncoding diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index f2512a3a5a..d0f23a7307 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -15,8 +15,9 @@ import Data.Maybe (fromMaybe) import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Util +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) data UITheme = UITheme diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 3f7d19fd6d..796b32778b 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -4,7 +4,7 @@ {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-orphans #-} -module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle) where +module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle, zipWith3') where import Control.Exception (Exception) import Control.Monad @@ -15,6 +15,7 @@ import Control.Monad.Reader import Data.Bifunctor (first) import qualified Data.ByteString.Lazy as LB import Data.List (sortBy) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Ord (comparing) import Data.Time (NominalDiffTime) import Data.Word (Word16) @@ -52,6 +53,9 @@ shuffle xs = map snd . sortBy (comparing fst) <$> mapM (\x -> (,x) <$> random) x random :: IO Word16 random = randomRIO (0, 65535) +zipWith3' :: (a -> b -> c -> d) -> NonEmpty a -> NonEmpty b -> NonEmpty c -> NonEmpty d +zipWith3' f ~(x :| xs) ~(y :| ys) ~(z :| zs) = f x y z :| zipWith3 f xs ys zs + liftIOEither :: (MonadIO m, MonadError e m) => IO (Either e a) -> m a liftIOEither a = liftIO a >>= liftEither {-# INLINE liftIOEither #-} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 84cb561396..54e888ea86 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -7,6 +8,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} @@ -17,7 +19,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB -import Data.Char (isSpace, toUpper) +import Data.Char (toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) @@ -37,7 +39,6 @@ import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.Version as V import qualified Network.HTTP.Types as Q import Numeric (showFFloat) -import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Help @@ -59,6 +60,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -86,29 +88,45 @@ data WCallCommand $(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "WCCall") ''WCallCommand) -serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> String -serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ +serializeChatError :: Bool -> ChatConfig -> ChatError -> String +serializeChatError isCmd cfg = unlines . map unStyle . chatErrorToView isCmd cfg -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 +serializeChatResponse :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> ChatConfig -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> r -> String +serializeChatResponse hu cfg ts tz remoteHost_ = unlines . map unStyle . responseToView hu cfg False ts tz remoteHost_ + +class ChatResponseEvent r where + responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> r -> [StyledString] + isCommandResponse :: Bool + +instance ChatResponseEvent ChatResponse where + responseToView = chatResponseToView + isCommandResponse = True + +instance ChatResponseEvent ChatEvent where + responseToView = chatEventToView + isCommandResponse = False + +chatErrorToView :: Bool -> ChatConfig -> ChatError -> [StyledString] +chatErrorToView isCmd ChatConfig {logLevel, testView} = viewChatError isCmd logLevel testView + +chatResponseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] +chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] - CRChatSuspended -> ["chat suspended"] CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRChatTags u tags -> ttyUser u $ [viewJSON tags] - CRApiParsedMarkdown ft -> [viewJSON ft] CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) 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 @@ -117,55 +135,26 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe "server queue info: " <> viewJSON qInfo ] CRContactSwitchStarted {} -> ["switch started"] - CRGroupMemberSwitchStarted {} -> ["switch started"] + CEvtGroupMemberSwitchStarted {} -> ["switch started"] CRContactSwitchAborted {} -> ["switch aborted"] - CRGroupMemberSwitchAborted {} -> ["switch aborted"] - CRContactSwitch u ct progress -> ttyUser u $ viewContactSwitch ct progress - CRGroupMemberSwitch u g m progress -> ttyUser u $ viewGroupMemberSwitch g m progress + CEvtGroupMemberSwitchAborted {} -> ["switch aborted"] CRContactRatchetSyncStarted {} -> ["connection synchronization started"] CRGroupMemberRatchetSyncStarted {} -> ["connection synchronization started"] - CRContactRatchetSync u ct progress -> ttyUser u $ viewContactRatchetSync ct progress - CRGroupMemberRatchetSync u g m progress -> ttyUser u $ viewGroupMemberRatchetSync g m progress - CRContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct - CRGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView - CRNewChatItems u chatItems - | length chatItems > 20 -> - if - | all (\aci -> aChatItemDir aci == MDRcv) chatItems -> ttyUser u [sShow (length chatItems) <> " new messages"] - | all (\aci -> aChatItemDir aci == MDSnd) chatItems -> ttyUser u [sShow (length chatItems) <> " messages sent"] - | otherwise -> ttyUser u [sShow (length chatItems) <> " new messages created"] - | otherwise -> - concatMap - (\(AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item) - chatItems + CRNewChatItems u chatItems -> viewChatItems ttyUser unmuted u chatItems ts tz CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] - CRChatItemsStatusesUpdated u chatItems - | length chatItems <= 20 -> - concatMap - (\ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts) - chatItems - | testView && showReceipts -> - ttyUser u [sShow (length chatItems) <> " message statuses updated"] - | otherwise -> [] CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci CRTagsUpdated u _ _ -> ttyUser u ["chat tags updated"] - CRChatItemsDeleted u deletions byUser timed -> case deletions of - [ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] -> - ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView - deletions' -> ttyUser u [sShow (length deletions') <> " messages deleted"] - CRGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u [ttyGroup' g <> ": " <> sShow (length ciIds) <> " messages deleted by " <> if byUser then "user" else "member" <> maybe "" (\m -> " " <> ttyMember m) member_] + CRChatItemsDeleted u deletions byUser timed -> ttyUser u $ viewChatItemsDeleted (unmuted u) deletions byUser timed ts tz testView + CRGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u $ viewGroupChatItemsDeleted g ciIds byUser member_ CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRReactionMembers u memberReactions -> ttyUser u $ viewReactionMembers memberReactions - CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc s f t -> ttyUser u $ viewSentBroadcast mc s f ts tz t - CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr - CRCmdAccepted _ -> [] CRCmdOk u_ -> ttyUser' u_ ["ok"] CRChatHelp section -> case section of HSMain -> chatHelpInfo @@ -181,54 +170,43 @@ 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 CRGroupMembers u g -> ttyUser u $ viewGroupMembers g + CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms + -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] + -- CRGroupConversationsDeleted u _g _conversations -> ttyUser u [] CRGroupsList u gs -> ttyUser u $ viewGroupsList gs - CRSentGroupInvitation u g c _ -> - ttyUser u $ - case contactConn c of - Just Connection {viaGroupLink} - | viaGroupLink -> [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] - | otherwise -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] - Nothing -> [] + CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci CRUserProfile u p -> ttyUser u $ viewUserProfile p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] - CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u' + CRUserPrivacy u u' -> ttyUserPrefix hu outputRH 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 + CRItemsReadForChat u _chatId -> ttyUser u ["items read for chat"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] - CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c - CRAcceptingBusinessRequest u g -> ttyUser u $ viewAcceptingBusinessRequest g 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..."] - CRBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRUserDeletedMembers u g members wm -> case members of + [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group" <> withMessages wm] + mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group" <> withMessages wm] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] - CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] - CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc - CRRcvFileDescrReady _ _ _ _ -> [] - CRRcvFileProgressXFTP {} -> [] CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts @@ -239,112 +217,29 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c CRGroupAliasUpdated u g -> ttyUser u $ viewGroupAliasUpdated g CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c - CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' - CRGroupMemberUpdated {} -> [] - CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' - CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile CRRcvStandaloneFileCreated u ft -> ttyUser u $ receivingFileStandalone "started" ft - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci - CRRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft - CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] - CRRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e] - CRRcvFileWarning u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "warning: " ci <> [sShow e] - CRRcvFileWarning u Nothing e ft -> ttyUser u $ receivingFileStandalone "warning: " ft <> [sShow e] - CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft - CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft - CRSndFileStartXFTP {} -> [] - CRSndFileProgressXFTP {} -> [] - CRSndFileRedirectStartXFTP u ft ftRedirect -> ttyUser u $ standaloneUploadRedirect ft ftRedirect - CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris - CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci - CRSndFileCancelledXFTP {} -> [] - CRSndFileError u Nothing ft e -> ttyUser u $ uploadingFileStandalone "error" ft <> [plain e] - CRSndFileError u (Just ci) _ e -> ttyUser u $ uploadingFile "error" ci <> [plain e] - CRSndFileWarning u Nothing ft e -> ttyUser u $ uploadingFileStandalone "warning: " ft <> [plain e] - CRSndFileWarning u (Just ci) _ e -> ttyUser u $ uploadingFile "warning: " ci <> [plain e] - CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> - ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [viewJSON j]) info_ - CRContactConnecting u _ -> ttyUser u [] - CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView - CRContactSndReady u ct -> ttyUser u [ttyFullContact ct <> ": you can send messages to contact"] - CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] - CRSubscriptionEnd u acEntity -> - let Connection {connId} = entityConnection acEntity - in ttyUser u [sShow connId <> ": END"] - CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactSubError u c e -> ttyUser u [ttyContact' c <> ": contact error " <> sShow e] - CRContactSubSummary u summary -> - ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" - where - (errors, subscribed) = partition (isJust . contactError) summary - CRUserContactSubSummary u summary -> - ttyUser u $ - map addressSS addresses - <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") - where - (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary - addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError - (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks - CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] - CRGroupInvitation u g -> ttyUser u [groupInvitation' g] - CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r - CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m - CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] - CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] - CRJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - CRConnectedToGroupMember u g m _ -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] - CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' - CRMemberRoleUser u g m r r' -> ttyUser u $ viewMemberRoleUserChanged g m r r' - CRMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked - CRMemberBlockedForAllUser u g m blocked -> ttyUser u $ viewMemberBlockedForAllUser g m blocked - CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g - CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] - CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] - CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] - CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] + CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m + CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"] + CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' + CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked 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 - CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' 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"] CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] - CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] - CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' - CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] - CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g - CRPendingSubSummary u _ -> ttyUser u [] - CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> - ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRRcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> - ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation RcvCallInvitation {user, contact, callType, sharedKey} -> ttyUser user $ viewCallInvitation contact callType sharedKey - CRCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey - CRCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer - CRCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] - CRCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"] CRCallInvitations _ -> [] - CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] - CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv] - CRNtfConns {} -> [] - CRConnNtfMessages {} -> [] - CRNtfMessage {} -> [] + CRNtfConns {ntfConns} -> map (\NtfConn {agentConnId, expectedMsg_} -> plain $ show agentConnId <> " " <> show expectedMsg_) ntfConns + CRConnNtfMessages ntfMsgs -> [sShow ntfMsgs] CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" @@ -359,40 +254,16 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ] where started = " started on " <> B.unpack (strEncode address) <> ":" <> ctrlPort - CRRemoteHostSessionCode {remoteHost_, sessionCode} -> - [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, - "Compare session code with host:", - plain sessionCode - ] - CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] - CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] - CRRemoteHostStopped {remoteHostId_} -> - [ maybe "new remote host" (mappend "remote host " . sShow) remoteHostId_ <> " stopped" - ] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> - [ ("remote controller " <> sShow remoteCtrlId <> " found: ") - <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ - ] - <> ["use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] - where - deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") <> viewRemoteCtrl ctrlAppInfo appVersion True ] - CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> - [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, - "Compare session code with controller and use:", - "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId - ] CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] - CRRemoteCtrlStopped {rcStopReason} -> viewRemoteCtrlStopped rcStopReason - CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] CRSQLResult rows -> map plain rows #if !defined(dbPostgres) CRArchiveExported archiveErrs -> if null archiveErrs then ["ok"] else ["archive export errors: " <> plain (show archiveErrs)] @@ -433,50 +304,21 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [ "agent queues info:", plain . LB.unpack $ J.encode agentQueuesInfo ] - CRContactDisabled u c -> ttyUser u ["[" <> ttyContact' c <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] - CRConnectionDisabled entity -> viewConnectionEntityDisabled entity - CRConnectionInactive entity inactive -> viewConnectionEntityInactive entity inactive - CRAgentRcvQueueDeleted acId srv aqId err_ -> - [ ("completed deleting rcv queue, agent connection id: " <> sShow acId) - <> (", server: " <> sShow srv) - <> (", agent queue id: " <> sShow aqId) - <> maybe "" (\e -> ", error: " <> sShow e) err_ - | logLevel <= CLLInfo - ] - CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo] - CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] - CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] - CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e - CRChatError u e -> ttyUser' u $ viewChatError False logLevel testView e - CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError False logLevel testView) errs CRAppSettings as -> ["app settings: " <> viewJSON as] - CRTimedAction _ _ -> [] CRCustomChatResponse u r -> ttyUser' u $ map plain $ T.lines r where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss - | (showNtfs && isNothing viewPwdHash) || activeUser = ttyUserPrefix user ss + | (showNtfs && isNothing viewPwdHash) || activeUser = ttyUserPrefix hu outputRH user ss | otherwise = [] - ttyUserPrefix :: User -> [StyledString] -> [StyledString] - ttyUserPrefix _ [] = [] - ttyUserPrefix User {userId, localDisplayName = u} ss - | null prefix = ss - | otherwise = prependFirst ("[" <> mconcat prefix <> "] ") ss - where - prefix = intersperse ", " $ remotePrefix <> userPrefix - remotePrefix = [maybe "local" (("remote: " <>) . highlight . show) outputRH | outputRH /= currentRH] - userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] - currentUserId = (\User {userId = uId} -> uId) <$> user_ ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser - ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] - ttyUserPrefix' = maybe id ttyUserPrefix testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) - toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) + toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName} _scopeInfo) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) toChatView (AChat _ (Chat (LocalChat _) items _)) = ("*", toCIPreview items Nothing, Nothing) toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) @@ -501,12 +343,207 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText}}) membership_ = let deleted_ = maybe "" (\t -> " [" <> t <> "]") (chatItemDeletedText ci membership_) in itemText <> deleted_ - viewErrorsSummary :: [a] -> StyledString -> [StyledString] - viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] - contactList :: [ContactRef] -> String - contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isMention ci + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci + unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] + unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False + unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] + unmuted' u chat chatDir mention s + | chatDirNtf u chat chatDir mention = s + | testView = map (<> " ") s + | otherwise = [] + withMessages wm = if wm then " with all messages" else "" + +ttyUserPrefix :: (Maybe RemoteHostId, Maybe User) -> Maybe RemoteHostId -> User -> [StyledString] -> [StyledString] +ttyUserPrefix _ _ _ [] = [] +ttyUserPrefix (currentRH, user_) outputRH User {userId, localDisplayName = u} ss + | null prefix = ss + | otherwise = prependFirst ("[" <> mconcat prefix <> "] ") ss + where + prefix = intersperse ", " $ remotePrefix <> userPrefix + remotePrefix = [maybe "local" (("remote: " <>) . highlight . show) outputRH | outputRH /= currentRH] + userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] + currentUserId = (\User {userId = uId} -> uId) <$> user_ + +viewErrorsSummary :: [a] -> StyledString -> [StyledString] +viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] + +contactList :: [ContactRef] -> String +contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs + +chatEventToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatEvent -> [StyledString] +chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case + CEvtChatSuspended -> ["chat suspended"] + CEvtContactSwitch u ct progress -> ttyUser u $ viewContactSwitch ct progress + CEvtGroupMemberSwitch u g m progress -> ttyUser u $ viewGroupMemberSwitch g m progress + CEvtContactRatchetSync u ct progress -> ttyUser u $ viewContactRatchetSync ct progress + CEvtGroupMemberRatchetSync u g m progress -> ttyUser u $ viewGroupMemberRatchetSync g m progress + CEvtNewChatItems u chatItems -> viewChatItems ttyUser unmuted u chatItems ts tz + CEvtChatItemsStatusesUpdated u chatItems + | length chatItems <= 20 -> + concatMap + (\ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts) + chatItems + | testView && showReceipts -> + ttyUser u [sShow (length chatItems) <> " message statuses updated"] + | otherwise -> [] + CEvtChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz + CEvtChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci + CEvtChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz + CEvtChatItemsDeleted u deletions byUser timed -> ttyUser u $ viewChatItemsDeleted (unmuted u) deletions byUser timed ts tz testView + CEvtGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u $ viewGroupChatItemsDeleted g ciIds byUser member_ + CEvtChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] + CEvtUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] + CEvtSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c + CEvtContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] + CEvtAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c + CEvtAcceptingBusinessRequest u g -> ttyUser u $ viewAcceptingBusinessRequest g + CEvtContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] + CEvtBusinessRequestAlreadyAccepted u g -> ttyUser u [ttyFullGroup g <> ": sent you a duplicate connection request, but you are already connected, no action needed"] + CEvtGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] + CEvtBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] + CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] + CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] + CEvtUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] + CEvtRcvFileDescrReady _ _ _ _ -> [] + CEvtRcvFileAccepted u ci -> ttyUser u $ savingFile' ci + CEvtRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft + CEvtRcvFileProgressXFTP {} -> [] + CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CEvtGroupMemberUpdated {} -> [] + CEvtContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile + CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci + CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci + CEvtRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft + CEvtRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft + CEvtRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] + CEvtRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e] + CEvtRcvFileWarning u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "warning: " ci <> [sShow e] + CEvtRcvFileWarning u Nothing e ft -> ttyUser u $ receivingFileStandalone "warning: " ft <> [sShow e] + CEvtSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft + CEvtSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft + CEvtSndFileStartXFTP {} -> [] + CEvtSndFileProgressXFTP {} -> [] + CEvtSndFileRedirectStartXFTP u ft ftRedirect -> ttyUser u $ standaloneUploadRedirect ft ftRedirect + CEvtSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris + CEvtSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci + CEvtSndFileCancelledXFTP {} -> [] + CEvtSndFileError u Nothing ft e -> ttyUser u $ uploadingFileStandalone "error" ft <> [plain e] + CEvtSndFileError u (Just ci) _ e -> ttyUser u $ uploadingFile "error" ci <> [plain e] + CEvtSndFileWarning u Nothing ft e -> ttyUser u $ uploadingFileStandalone "warning: " ft <> [plain e] + CEvtSndFileWarning u (Just ci) _ e -> ttyUser u $ uploadingFile "warning: " ci <> [plain e] + CEvtSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> + ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] + CEvtContactConnecting u _ -> ttyUser u [] + CEvtContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView + CEvtContactSndReady u ct -> ttyUser u [ttyFullContact ct <> ": you can send messages to contact"] + CEvtContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] + CEvtSubscriptionEnd u acEntity -> + let Connection {connId} = entityConnection acEntity + in ttyUser u [sShow connId <> ": END"] + CEvtContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CEvtContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CEvtContactSubError u c e -> ttyUser u [ttyContact' c <> ": contact error " <> sShow e] + CEvtContactSubSummary u summary -> + ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" + where + (errors, subscribed) = partition (isJust . contactError) summary + CEvtUserContactSubSummary u summary -> + ttyUser u $ + map addressSS addresses + <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") + where + (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary + addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError + (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks + CEvtNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] + CEvtNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] + CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r + CEvtUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g + CEvtJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m + CEvtHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] + CEvtHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] + CEvtJoinedGroupMemberConnecting u g host m -> ttyUser u $ viewJoinedGroupMemberConnecting g host m + CEvtConnectedToGroupMember u g m _ -> ttyUser u $ viewConnectedToGroupMember g m + CEvtMemberAcceptedByOther u g acceptingMember m -> ttyUser u $ viewMemberAcceptedByOther g acceptingMember m + CEvtMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' + CEvtMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked + CEvtDeletedMemberUser u g by wm -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group" <> withMessages wm] <> groupPreserved g + CEvtDeletedMember u g by m wm -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group" <> withMessages wm] + CEvtLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] + CEvtGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] + CEvtGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m + CEvtAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] + CEvtNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] + CEvtNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] + CEvtContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' + CEvtCallInvitation RcvCallInvitation {user, contact, callType, sharedKey} -> ttyUser user $ viewCallInvitation contact callType sharedKey + CEvtCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey + CEvtCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer + CEvtCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] + CEvtCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"] + CEvtNtfMessage {} -> [] + CEvtRemoteHostSessionCode {remoteHost_, sessionCode} -> + [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, + "Compare session code with host:", + plain sessionCode + ] + CEvtNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] + CEvtRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] + CEvtRemoteHostStopped {remoteHostId_} -> + [ maybe "new remote host" (mappend "remote host " . sShow) remoteHostId_ <> " stopped" + ] + CEvtRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> + [ ("remote controller " <> sShow remoteCtrlId <> " found: ") + <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ + ] + <> ["use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] + where + deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " + CEvtRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> + [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, + "Compare session code with controller and use:", + "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId + ] + CEvtRemoteCtrlStopped {rcStopReason} -> viewRemoteCtrlStopped rcStopReason + CEvtContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] + CEvtContactDisabled u c -> ttyUser u ["[" <> ttyContact' c <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] + CEvtConnectionDisabled entity -> viewConnectionEntityDisabled entity + CEvtConnectionInactive entity inactive -> viewConnectionEntityInactive entity inactive + CEvtAgentRcvQueuesDeleted delQs -> ["completed deleting rcv queues: " <> sShow (length delQs) | logLevel <= CLLInfo] + CEvtAgentConnsDeleted acIds -> ["completed deleting connections: " <> sShow (length acIds) | logLevel <= CLLInfo] + CEvtAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] + CEvtMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] + CEvtChatErrors errs -> concatMap (viewChatError False logLevel testView) errs + CEvtTimedAction _ _ -> [] + CEvtTerminalEvent te -> case te of + TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] + TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason] + TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] + TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct + TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m + TEGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g + TEGroupInvitation u g -> ttyUser u [groupInvitationSub g] + TEGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] + TEMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] + TEMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" + TEPendingSubSummary u _ -> ttyUser u [] + TESndFileSubError u SndFileTransfer {fileId, fileName} e -> + ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + TERcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> + ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CEvtCustomChatEvent u r -> ttyUser' u $ map plain $ T.lines r + where + ttyUser :: User -> [StyledString] -> [StyledString] + ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss + | (showNtfs && isNothing viewPwdHash) || activeUser = ttyUserPrefix hu outputRH user ss + | otherwise = [] + ttyUser' :: Maybe User -> [StyledString] -> [StyledString] + ttyUser' = maybe id ttyUser + withMessages wm = if wm then " with all messages" else "" + unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] @@ -521,7 +558,7 @@ userNtf User {showNtfs, activeUser} = showNtfs || activeUser chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool chatDirNtf user cInfo chatDir mention = case (cInfo, chatDir) of (DirectChat ct, CIDirectRcv) -> contactNtf user ct mention - (GroupChat g, CIGroupRcv m) -> groupNtf user g mention && not (blockedByAdmin m) && showMessages (memberSettings m) + (GroupChat g _scopeInfo, CIGroupRcv m) -> groupNtf user g mention && not (blockedByAdmin m) && showMessages (memberSettings m) _ -> True contactNtf :: User -> Contact -> Bool -> Bool @@ -571,8 +608,8 @@ viewUsersList us = <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -viewGroupSubscribed :: GroupInfo -> [StyledString] -viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] +viewGroupSubscribed :: GroupName -> [StyledString] +viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String showSMPServer ProtocolServer {host} = B.unpack $ strEncode host @@ -591,11 +628,28 @@ viewChats ts tz = concatMap chatPreview . reverse where chatName = case chat of DirectChat ct -> [" " <> ttyToContact' ct] - GroupChat g -> [" " <> ttyToGroup g] + GroupChat g scopeInfo -> [" " <> ttyToGroup g scopeInfo] _ -> [] +viewChatItems :: + (User -> [StyledString] -> [StyledString]) -> + (forall c d. User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString]) -> + User -> + [AChatItem] -> + UTCTime -> + TimeZone -> + [StyledString] +viewChatItems ttyUser unmuted u chatItems ts tz + | length chatItems <= 20 = + concatMap + (\(AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item) + chatItems + | all (\aci -> aChatItemDir aci == MDRcv) chatItems = ttyUser u [sShow (length chatItems) <> " new messages"] + | all (\aci -> aChatItemDir aci == MDSnd) chatItems = ttyUser u [sShow (length chatItems) <> " messages sent"] + | otherwise = ttyUser u [sShow (length chatItems) <> " new messages created"] + viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember}, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> viewCI where viewCI = case chat of @@ -619,22 +673,22 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa (maybe [] forwardedFrom itemForwarded) (directQuote chatDir) quotedItem - GroupChat g -> case chatDir of + GroupChat g scopeInfo -> case chatDir of CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to context mc CISndGroupInvitation {} -> showSndItemProhibited to _ -> showSndItem to where - to = ttyToGroup g + to = ttyToGroup g scopeInfo CIGroupRcv m -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta CIRcvGroupInvitation {} -> showRcvItemProhibited from - CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False - CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False + CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False + CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False _ -> showRcvItem from where - from = ttyFromGroup g m + from = ttyFromGroupAttention g scopeInfo m userMention where context = maybe @@ -712,8 +766,8 @@ viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTime fwdDir_ = case (fwdMsgDir, fwdChatInfo) of (SMDSnd, DirectChat ct) -> Just $ "you @" <> viewContactName ct (SMDRcv, DirectChat ct) -> Just $ "@" <> viewContactName ct - (SMDSnd, GroupChat gInfo) -> Just $ "you #" <> viewGroupName gInfo - (SMDRcv, GroupChat gInfo) -> Just $ "#" <> viewGroupName gInfo + (SMDSnd, GroupChat gInfo _scopeInfo) -> Just $ "you #" <> viewGroupName gInfo + (SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> viewGroupName gInfo _ -> Nothing fwdItemId = "chat item id: " <> (T.pack . show $ aChatItemId fwdACI) _ -> [] @@ -727,14 +781,10 @@ localTs tz ts = do viewChatItemStatusUpdated :: AChatItem -> CurrentTime -> TimeZone -> Bool -> Bool -> [StyledString] viewChatItemStatusUpdated (AChatItem _ _ chat item@ChatItem {meta = CIMeta {itemStatus}}) ts tz testView showReceipts = case itemStatus of - CISSndRcvd rcptStatus SSPPartial -> - if testView && showReceipts - then prependFirst (viewDeliveryReceiptPartial rcptStatus <> " ") $ viewChatItem chat item False ts tz - else [] - CISSndRcvd rcptStatus SSPComplete -> - if testView && showReceipts - then prependFirst (viewDeliveryReceipt rcptStatus <> " ") $ viewChatItem chat item False ts tz - else [] + CISSndRcvd rcptStatus SSPPartial | testView && showReceipts -> + prependFirst (viewDeliveryReceiptPartial rcptStatus <> " ") $ viewChatItem chat item False ts tz + CISSndRcvd rcptStatus SSPComplete | testView && showReceipts -> + prependFirst (viewDeliveryReceipt rcptStatus <> " ") $ viewChatItem chat item False ts tz _ -> [] viewDeliveryReceiptPartial :: MsgReceiptStatus -> StyledString @@ -768,19 +818,19 @@ viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, itemEd (maybe [] forwardedFrom itemForwarded) (directQuote chatDir) quotedItem - GroupChat g -> case chatDir of + GroupChat g scopeInfo -> case chatDir of CIGroupRcv m -> case content of CIRcvMsgContent mc | itemLive == Just True && not liveItems -> [] | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta _ -> [] where - from = if itemEdited then ttyFromGroupEdited g m else ttyFromGroup g m + from = if itemEdited then ttyFromGroupEdited g scopeInfo m else ttyFromGroup g scopeInfo m CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ viewSentMessage to context mc ts tz meta _ -> [] where - to = if itemEdited then ttyToGroupEdited g else ttyToGroup g + to = if itemEdited then ttyToGroupEdited g scopeInfo else ttyToGroup g scopeInfo where context = maybe @@ -798,6 +848,23 @@ viewItemNotChanged (AChatItem _ msgDir _ _) = case msgDir of SMDSnd -> ["message didn't change"] SMDRcv -> [] +viewChatItemsDeleted :: + (forall c d. ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString]) -> + [ChatItemDeletion] -> + Bool -> + Bool -> + UTCTime -> + TimeZone -> + Bool -> + [StyledString] +viewChatItemsDeleted unmuted deletions byUser timed ts tz testView = case deletions of + [ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] -> + unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView + deletions' -> [sShow (length deletions') <> " messages deleted"] + +viewGroupChatItemsDeleted :: GroupInfo -> [ChatItemId] -> Bool -> Maybe GroupMember -> [StyledString] +viewGroupChatItemsDeleted g ciIds byUser member_ = [ttyGroup' g <> ": " <> sShow (length ciIds) <> " messages deleted by " <> if byUser then "user" else "member" <> maybe "" (\m -> " " <> ttyMember m) member_] + viewItemDelete :: ChatInfo c -> ChatItem c d -> Maybe AChatItem -> Bool -> Bool -> CurrentTime -> TimeZone -> Bool -> [StyledString] viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem byUser timed ts tz testView | timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView] @@ -806,10 +873,10 @@ viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem DirectChat c -> case (chatDir, deletedContent) of (CIDirectRcv, CIRcvMsgContent mc) -> viewReceivedMessage (ttyFromContactDeleted c deletedText_) [] mc ts tz meta _ -> prohibited - GroupChat g -> case ciMsgContent deletedContent of + GroupChat g scopeInfo -> case ciMsgContent deletedContent of Just mc -> let m = chatItemMember g ci - in viewReceivedMessage (ttyFromGroupDeleted g m deletedText_) [] mc ts tz meta + in viewReceivedMessage (ttyFromGroupDeleted g scopeInfo m deletedText_) [] mc ts tz meta _ -> prohibited _ -> prohibited where @@ -828,11 +895,11 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md where from = ttyFromContact c reactionMsg mc = quoteText mc $ if toMsgDirection md == MDSnd then ">>" else ">" - (GroupChat g, CIGroupRcv m) -> case ciMsgContent content of + (GroupChat g scopeInfo, CIGroupRcv m) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc _ -> [] where - from = ttyFromGroup g m + from = ttyFromGroup g scopeInfo m reactionMsg mc = quoteText mc . ttyQuotedMember . Just $ sentByMember' g itemDir (LocalChat _, CILocalRcv) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc @@ -913,14 +980,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 @@ -936,7 +1006,7 @@ viewContactNotFound cName suspectedMember = viewChatCleared :: AChatInfo -> [StyledString] viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"] - GroupChat gi -> [ttyGroup' gi <> ": all messages are removed locally ONLY"] + GroupChat gi _scopeInfo -> [ttyGroup' gi <> ": all messages are removed locally ONLY"] LocalChat _ -> ["notes: all messages are removed"] ContactRequest _ -> [] ContactConnection _ -> [] @@ -975,21 +1045,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} -> @@ -1002,16 +1080,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 = @@ -1079,14 +1160,53 @@ viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortO viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) viewUserJoinedGroup :: GroupInfo -> [StyledString] -viewUserJoinedGroup g = +viewUserJoinedGroup g@GroupInfo {membership} = case incognitoMembershipProfile g of - Just mp -> [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile mp)] - Nothing -> [ttyGroup' g <> ": you joined the group"] + Just mp -> [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile mp) <> pendingApproval_] + Nothing -> [ttyGroup' g <> ": you joined the group" <> pendingApproval_] + where + pendingApproval_ = case memberStatus membership of + GSMemPendingApproval -> ", pending approval" + GSMemPendingReview -> ", connecting to group moderators for admission to group" + _ -> "" viewJoinedGroupMember :: GroupInfo -> GroupMember -> [StyledString] -viewJoinedGroupMember g m = - [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "] +viewJoinedGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, memberStatus} = case memberStatus of + GSMemPendingApproval -> + [ (ttyGroup' g <> ": " <> ttyMember m <> " connected and pending approval, ") + <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") + ] + GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " connected and pending review"] + _ -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group"] + +viewMemberAccepted :: GroupInfo -> GroupMember -> [StyledString] +viewMemberAccepted g m@GroupMember {memberStatus} = case memberStatus of + GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted and pending review (will introduce moderators)"] + _ -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted"] + +viewMemberAcceptedByOther :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] +viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory, memberStatus} = case memberCategory of + GCUserMember -> case memberStatus of + GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted you to the group, pending review"] + _ -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted you to the group [warning - unexpected]"] + GCInviteeMember -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted " <> ttyMember m <> " to the group (will introduce remaining members)"] + _ -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted " <> ttyMember m <> " to the group"] + +viewJoinedGroupMemberConnecting :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] +viewJoinedGroupMemberConnecting g@GroupInfo {groupId} host m@GroupMember {groupMemberId, memberStatus} = case memberStatus of + GSMemPendingReview -> + [ (ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting and pending review...), ") + <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") + ] + _ -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + +viewConnectedToGroupMember :: GroupInfo -> GroupMember -> [StyledString] +viewConnectedToGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, memberStatus} = case memberStatus of + GSMemPendingReview -> + [ (ttyGroup' g <> ": " <> connectedMember m <> " is connected and pending review, ") + <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") + ] + _ -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] viewReceivedGroupInvitation g c role = @@ -1114,21 +1234,19 @@ viewMemberRoleChanged g@GroupInfo {membership} by m r r' memId = groupMemberId' m view s = [ttyGroup' g <> ": " <> ttyMember by <> " changed " <> s <> " from " <> showRole r <> " to " <> showRole r'] -viewMemberRoleUserChanged :: GroupInfo -> GroupMember -> GroupMemberRole -> GroupMemberRole -> [StyledString] -viewMemberRoleUserChanged g@GroupInfo {membership} m r r' - | r == r' = [ttyGroup' g <> ": member role did not change"] - | groupMemberId' membership == groupMemberId' m = view "your role" - | otherwise = view $ "the role of " <> ttyMember m - where - view s = [ttyGroup' g <> ": you changed " <> s <> " from " <> showRole r <> " to " <> showRole r'] +viewMemberRoleUserChanged :: GroupInfo -> [GroupMember] -> GroupMemberRole -> [StyledString] +viewMemberRoleUserChanged g members r = case members of + [m] -> [ttyGroup' g <> ": you changed the role of " <> ttyMember m <> " to " <> showRole r] + mems' -> [ttyGroup' g <> ": you changed the role of " <> sShow (length mems') <> " members to " <> showRole r] viewMemberBlockedForAll :: GroupInfo -> GroupMember -> GroupMember -> Bool -> [StyledString] viewMemberBlockedForAll g by m blocked = [ttyGroup' g <> ": " <> ttyMember by <> " " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] -viewMemberBlockedForAllUser :: GroupInfo -> GroupMember -> Bool -> [StyledString] -viewMemberBlockedForAllUser g m blocked = - [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] +viewMembersBlockedForAllUser :: GroupInfo -> [GroupMember] -> Bool -> [StyledString] +viewMembersBlockedForAllUser g members blocked = case members of + [m] -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] + mems' -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> sShow (length mems') <> " members"] showRole :: GroupMemberRole -> StyledString showRole = plain . strEncode @@ -1136,7 +1254,7 @@ showRole = plain . strEncode viewGroupMembers :: Group -> [StyledString] viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members where - removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft + removedOrLeft m = let s = memberStatus m in s == GSMemRejected || s == GSMemRemoved || s == GSMemLeft groupMember m = memIncognito m <> ttyFullMember m <> ": " <> plain (intercalate ", " $ [role m] <> category m <> status m <> muted m) role :: GroupMember -> String role GroupMember {memberRole} = B.unpack $ strEncode memberRole @@ -1146,6 +1264,7 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt GCHostMember -> ["host"] _ -> [] status m = case memberStatus m of + GSMemRejected -> ["rejected"] GSMemRemoved -> ["removed"] GSMemLeft -> ["left"] GSMemUnknown -> ["status unknown"] @@ -1159,6 +1278,18 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt | not (showMessages $ memberSettings m) = ["blocked"] | otherwise = [] +viewMemberSupportChats :: GroupInfo -> [GroupMember] -> [StyledString] +viewMemberSupportChats GroupInfo {membership} ms = support <> map groupMember ms + where + support = case supportChat membership of + Just sc -> ["support: " <> chatStats sc] + Nothing -> [] + groupMember m@GroupMember {supportChat} = case supportChat of + Just sc -> memIncognito m <> ttyFullMember m <> (" (id " <> sShow (groupMemberId' m) <> "): ") <> chatStats sc + Nothing -> "" + chatStats GroupSupportChat {unread, memberAttention, mentions} = + "unread: " <> sShow unread <> ", require attention: " <> sShow memberAttention <> ", mentions: " <> sShow mentions + viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected ct userIncognitoProfile testView = case userIncognitoProfile of @@ -1186,6 +1317,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g where viewMemberStatus = \case + GSMemRejected -> delete "you are rejected" GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" @@ -1202,6 +1334,13 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs | localAlias == "" = "" | otherwise = " (alias: " <> plain localAlias <> ")" +viewSentGroupInvitation :: GroupInfo -> Contact -> [StyledString] +viewSentGroupInvitation g c = case contactConn c of + Just Connection {viaGroupLink} + | viaGroupLink -> [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] + | otherwise -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + Nothing -> [] + groupInvitation' :: GroupInfo -> StyledString groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = highlight ("#" <> viewName ldn) @@ -1216,6 +1355,15 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " +groupInvitationSub :: ShortGroupInfo -> StyledString +groupInvitationSub ShortGroupInfo {groupName = ldn} = + highlight ("#" <> viewName ldn) + <> " - you are invited (" + <> highlight ("/j " <> viewName ldn) + <> " to join, " + <> highlight ("/d #" <> viewName ldn) + <> " to delete invitation)" + viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, @@ -1390,7 +1538,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]) @@ -1422,7 +1570,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 @@ -1584,10 +1732,10 @@ countactUserPrefText cup = case cup of viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString] viewGroupUpdated - GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, description, image, groupPreferences = gps}} - g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', description = description', image = image', groupPreferences = gps'}} + GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, description, image, groupPreferences = gps, memberAdmission = ma}} + g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}} m = do - let update = groupProfileUpdated <> groupPrefsUpdated + let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated if null update then [] else memberUpdated <> update @@ -1608,6 +1756,9 @@ viewGroupUpdated | otherwise = Just . plain $ groupPreferenceText (pref gps') where pref = getGroupPreference f . mergeGroupPreferences + memberAdmissionUpdated + | ma == ma' = [] + | otherwise = ["changed member admission rules"] viewGroupProfile :: GroupInfo -> [StyledString] viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {description, image, groupPreferences = gps}} = @@ -1648,21 +1799,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"] @@ -1694,13 +1848,14 @@ viewConnectionPlan = \case GLPConnectingProhibit (Just g) -> [grpOrBiz g <> " link: connecting to " <> grpOrBiz g <> " " <> ttyGroup' g] GLPKnown g -> [ grpOrBiz g <> " link: known " <> grpOrBiz g <> " " <> ttyGroup' g, - "use " <> ttyToGroup g <> highlight' "" <> " to send messages" + "use " <> ttyToGroup g Nothing <> highlight' "" <> " to send messages" ] where grpLink = ("group link: " <>) grpOrBiz GroupInfo {businessChat} = case businessChat of Just _ -> "business" Nothing -> "group" + CPError e -> viewChatError False logLevel testView e viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated @@ -1823,7 +1978,7 @@ uploadingFile :: StyledString -> AChatItem -> [StyledString] uploadingFile status = \case AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd} -> [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c] - AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} -> + AChatItem _ _ (GroupChat g _scopeInfo) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} -> [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g] _ -> [status <> " uploading file"] @@ -2123,8 +2278,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) <> ")") @@ -2176,7 +2331,6 @@ viewChatError isCmd logLevel testView = \case CEFileNotApproved fileId unknownSrvs -> ["file " <> sShow fileId <> " aborted, unknwon XFTP servers:"] <> map (plain . show) unknownSrvs CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"] CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."] - CEInvalidQuote -> ["cannot reply to this message"] CEInvalidForward -> ["cannot forward message(s)"] CEInvalidChatItemUpdate -> ["cannot update this item"] CEInvalidChatItemDelete -> ["cannot delete this item"] @@ -2370,18 +2524,26 @@ ttyFullGroup :: GroupInfo -> StyledString ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullName}} = ttyGroup g <> optFullName g fullName -ttyFromGroup :: GroupInfo -> GroupMember -> StyledString -ttyFromGroup g m = membershipIncognito g <> ttyFrom (fromGroup_ g m) +ttyFromGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString +ttyFromGroup g scopeInfo m = ttyFromGroupAttention g scopeInfo m False -ttyFromGroupEdited :: GroupInfo -> GroupMember -> StyledString -ttyFromGroupEdited g m = membershipIncognito g <> ttyFrom (fromGroup_ g m <> "[edited] ") +ttyFromGroupAttention :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> StyledString +ttyFromGroupAttention g scopeInfo m attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g scopeInfo m attention) -ttyFromGroupDeleted :: GroupInfo -> GroupMember -> Maybe Text -> StyledString -ttyFromGroupDeleted g m deletedText_ = - membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) +ttyFromGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString +ttyFromGroupEdited g scopeInfo m = membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> "[edited] ") -fromGroup_ :: GroupInfo -> GroupMember -> Text -fromGroup_ g m = "#" <> viewGroupName g <> " " <> viewMemberName m <> "> " +ttyFromGroupDeleted :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Maybe Text -> StyledString +ttyFromGroupDeleted g scopeInfo m deletedText_ = + membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) + +fromGroup_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Text +fromGroup_ g scopeInfo m = fromGroupAttention_ g scopeInfo m False + +fromGroupAttention_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> Text +fromGroupAttention_ g scopeInfo m attention = + let attn = if attention then "!" else "" + in "#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo <> viewMemberName m <> attn <> "> " ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow @@ -2389,14 +2551,18 @@ ttyFrom = styled $ colored Yellow ttyTo :: Text -> StyledString ttyTo = styled $ colored Cyan -ttyToGroup :: GroupInfo -> StyledString -ttyToGroup g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " ") +ttyToGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString +ttyToGroup g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo) -ttyToGroupEdited :: GroupInfo -> StyledString -ttyToGroupEdited g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " [edited] ") +ttyToGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString +ttyToGroupEdited g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> groupScopeInfoStr scopeInfo <> " [edited] ") -viewName :: Text -> Text -viewName s = if T.any isSpace s then "'" <> s <> "'" else s +groupScopeInfoStr :: Maybe GroupChatScopeInfo -> Text +groupScopeInfoStr = \case + Nothing -> "" + Just (GCSIMemberSupport {groupMember_}) -> case groupMember_ of + Nothing -> "(support) " + Just m -> "(support: " <> viewMemberName m <> ") " ttyFilePath :: FilePath -> StyledString ttyFilePath = plain diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 7d10f6a34a..71142c8b60 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -8,6 +8,7 @@ module Bots.BroadcastTests where import Broadcast.Bot import Broadcast.Options import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (bracket) @@ -21,7 +22,7 @@ import Test.Hspec hiding (it) import System.FilePath (()) #endif -broadcastBotTests :: SpecWith FilePath +broadcastBotTests :: SpecWith TestParams broadcastBotTests = do it "should broadcast message" testBroadcastMessages @@ -34,8 +35,8 @@ withBroadcastBot opts test = broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkBotOpts :: FilePath -> [KnownContact] -> BroadcastBotOpts -mkBotOpts tmp publishers = +mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts +mkBotOpts ps publishers = BroadcastBotOpts { coreOptions = testCoreOpts @@ -44,7 +45,7 @@ mkBotOpts tmp publishers = #if defined(dbPostgres) {dbSchemaPrefix = "client_" <> botDbPrefix} #else - {dbFilePrefix = tmp botDbPrefix} + {dbFilePrefix = tmpPath ps botDbPrefix} #endif }, @@ -56,19 +57,19 @@ mkBotOpts tmp publishers = botDbPrefix :: FilePath botDbPrefix = "broadcast_bot" -testBroadcastMessages :: HasCallStack => FilePath -> IO () -testBroadcastMessages tmp = do +testBroadcastMessages :: HasCallStack => TestParams -> IO () +testBroadcastMessages ps = do botLink <- - withNewTestChat tmp botDbPrefix broadcastBotProfile $ \bc_bot -> - withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat ps botDbPrefix broadcastBotProfile $ \bc_bot -> + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers bc_bot alice bc_bot ##> "/ad" getContactLink bc_bot True - let botOpts = mkBotOpts tmp [KnownContact 2 "alice"] + let botOpts = mkBotOpts ps [KnownContact 2 "alice"] withBroadcastBot botOpts $ - withTestChat tmp "alice" $ \alice -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withTestChat ps "alice" $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" bob `connectVia` botLink bob #> "@broadcast_bot hello" @@ -77,10 +78,11 @@ testBroadcastMessages tmp = do bob <## "I broadcast messages to all connected users from @alice." cath `connectVia` botLink alice #> "@broadcast_bot hello all!" + alice <# "broadcast_bot> hello all!" -- we broadcast to the sender too, /feed is used by bot bob <# "broadcast_bot> hello all!" cath <# "broadcast_bot> hello all!" alice <# "broadcast_bot> > hello all!" - alice <## " Forwarded to 2 contact(s)" + alice <## " Forwarded to 3 contact(s), 0 errors" where cc `connectVia` botLink = do cc ##> ("/c " <> botLink) diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 6cb11f3f93..c8b3d33916 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -7,19 +7,21 @@ module Bots.DirectoryTests where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) import Control.Monad (forM_, when) import qualified Data.Text as T -import qualified Directory.Events as DE +import Directory.Captcha import Directory.Options import Directory.Service import Directory.Store import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) import Simplex.Chat.Core +import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Types (Profile (..)) @@ -27,11 +29,12 @@ import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import System.FilePath (()) import Test.Hspec hiding (it) -directoryServiceTests :: SpecWith FilePath +directoryServiceTests :: SpecWith TestParams 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 @@ -51,9 +54,11 @@ directoryServiceTests = do describe "should require re-approval if profile is changed by" $ do it "the registration owner" testRegOwnerChangedProfile 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 + 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 @@ -62,14 +67,18 @@ directoryServiceTests = do it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval describe "list groups" $ do it "should list user's groups" testListUserGroups + describe "member admission" $ do + it "should ask member to pass captcha screen" testCapthaScreening describe "store log" $ do it "should restore directory service state" testRestoreDirectory + describe "captcha" $ do + it "should accept some incorrect spellings" testCaptcha directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkDirectoryOpts :: FilePath -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts -mkDirectoryOpts tmp superUsers ownersGroup = +mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts +mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = DirectoryOpts { coreOptions = testCoreOpts @@ -78,14 +87,20 @@ mkDirectoryOpts tmp superUsers ownersGroup = #if defined(dbPostgres) {dbSchemaPrefix = "client_" <> serviceDbPrefix} #else - {dbFilePrefix = tmp serviceDbPrefix} + {dbFilePrefix = ps serviceDbPrefix} #endif }, adminUsers = [], superUsers, ownersGroup, - directoryLog = Just $ tmp "directory_service.log", + blockedFragmentsFile = Nothing, + blockedWordsFile = Nothing, + blockedExtensionRules = Nothing, + nameSpellingFile = Nothing, + profileNameLimit = maxBound, + captchaGenerator = Nothing, + directoryLog = Just $ ps "directory_service.log", serviceName = "SimpleX-Directory", runCLI = False, searchResults = 3, @@ -96,13 +111,13 @@ serviceDbPrefix :: FilePath serviceDbPrefix = "directory_service" viewName :: String -> String -viewName = T.unpack . DE.viewName . T.pack +viewName = T.unpack . MD.viewName . T.pack -testDirectoryService :: HasCallStack => FilePath -> IO () -testDirectoryService tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDirectoryService :: HasCallStack => TestParams -> IO () +testDirectoryService ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink bob #> "@SimpleX-Directory privacy" bob <# "SimpleX-Directory> > privacy" @@ -116,7 +131,7 @@ testDirectoryService tmp = bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" bob ##> "/mr PSA SimpleX-Directory admin" -- putStrLn "*** discover service joins group and creates the link for profile" - bob <## "#PSA: you changed the role of SimpleX-Directory from member to admin" + bob <## "#PSA: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> Joining the group PSA…" bob <## "#PSA: SimpleX-Directory joined the group" bob <# "SimpleX-Directory> Joined the group PSA, creating the link…" @@ -174,8 +189,13 @@ testDirectoryService tmp = 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 <## "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 @@ -211,10 +231,10 @@ testDirectoryService tmp = su <## "To approve send:" su <# ("SimpleX-Directory> /approve 1:PSA " <> show grId) -testSuspendResume :: HasCallStack => FilePath -> IO () -testSuspendResume tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSuspendResume :: HasCallStack => TestParams -> IO () +testSuspendResume ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -229,7 +249,7 @@ testSuspendResume tmp = bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!" groupFound bob "privacy" superUser #> "@SimpleX-Directory privacy" - groupFoundN_ (Just 1) 2 superUser "privacy" + groupFoundN_ "" (Just 1) 2 superUser "privacy" superUser #> "@SimpleX-Directory /link 1:privacy" superUser <# "SimpleX-Directory> > /link 1:privacy" superUser <## " The link to join the group ID 1 (privacy):" @@ -240,10 +260,10 @@ testSuspendResume tmp = superUser <## " Forwarded to @bob, the owner of the group ID 1 (privacy)" bob <# "SimpleX-Directory> hello there" -testDeleteGroup :: HasCallStack => FilePath -> IO () -testDeleteGroup tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testDeleteGroup :: HasCallStack => TestParams -> IO () +testDeleteGroup ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -252,11 +272,45 @@ testDeleteGroup tmp = bob <## " Your group privacy is deleted from the directory" groupNotFound bob "privacy" -testSetRole :: HasCallStack => FilePath -> IO () -testSetRole tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +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 cath "privacy" + listUserGroup bob "privacy" "Privacy" + groupNotFound bob "security" + sendListCommand cath 0 + -- another user can register the group with the same name + registerGroupId superUser bob "security" "Security" 4 2 + +testSetRole :: HasCallStack => TestParams -> IO () +testSetRole ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -281,12 +335,12 @@ testSetRole tmp = cath ##> "#privacy hello" cath <## "#privacy: you don't have permission to send messages" -testJoinGroup :: HasCallStack => FilePath -> IO () -testJoinGroup tmp = - withDirectoryServiceCfg tmp testCfgGroupLinkViaContact $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> - withNewTestChatCfg tmp testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do +testJoinGroup :: HasCallStack => TestParams -> IO () +testJoinGroup ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -300,10 +354,10 @@ testJoinGroup tmp = cath <## "2 members" cath ##> ("/c " <> groupLink) cath <## "connection request sent!" - cath <## "SimpleX-Directory_1: contact is connected" - cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" + cath <## "use @SimpleX-Directory to send messages" cath <# ("#privacy SimpleX-Directory> " <> welcomeMsg) cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" @@ -315,11 +369,9 @@ testJoinGroup tmp = concurrentlyN_ [ do bob <## "dan (Daniel): accepting request to join group #privacy..." - bob <## "dan (Daniel): contact is connected" - bob <## "dan invited to group #privacy via your group link" bob <## "#privacy: dan joined the group", do - dan <## "bob (Bob): contact is connected" + dan <## "#privacy: joining the group..." dan <## "#privacy: you joined the group" dan <# ("#privacy bob> " <> welcomeMsg) dan @@ -331,10 +383,10 @@ testJoinGroup tmp = cath <## "#privacy: new member dan is connected" ] -testGroupNameWithSpaces :: HasCallStack => FilePath -> IO () -testGroupNameWithSpaces tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupNameWithSpaces :: HasCallStack => TestParams -> IO () +testGroupNameWithSpaces ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "Privacy & Security" "" groupFound bob "Privacy & Security" @@ -349,11 +401,11 @@ testGroupNameWithSpaces tmp = bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is listed in the directory again!" groupFound bob "Privacy & Security" -testSearchGroups :: HasCallStack => FilePath -> IO () -testSearchGroups tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testSearchGroups :: HasCallStack => TestParams -> IO () +testSearchGroups ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink forM_ [1..8 :: Int] $ \i -> registerGroupId superUser bob (groups !! (i - 1)) "" i i @@ -435,10 +487,10 @@ testSearchGroups tmp = u <##. "Link to join the group " u <## (show count <> " members") -testInviteToOwnersGroup :: HasCallStack => FilePath -> IO () -testInviteToOwnersGroup tmp = - withDirectoryServiceCfgOwnersGroup tmp testCfg True $ \superUser dsLink -> - withNewTestChatCfg tmp testCfg "bob" bobProfile $ \bob -> do +testInviteToOwnersGroup :: HasCallStack => TestParams -> IO () +testInviteToOwnersGroup ps = + withDirectoryServiceCfgOwnersGroup ps testCfg True $ \superUser dsLink -> + withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroupId superUser bob "privacy" "Privacy" 2 1 bob <## "#owners: SimpleX-Directory invites you to join the group as member" @@ -453,11 +505,11 @@ testInviteToOwnersGroup tmp = registerGroupId superUser bob "security" "Security" 3 2 superUser <## "Owner is already a member of owners' group" -testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () -testDelistedOwnerLeaves tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedOwnerLeaves :: HasCallStack => TestParams -> IO () +testDelistedOwnerLeaves ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -467,13 +519,16 @@ testDelistedOwnerLeaves tmp = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" -testDelistedOwnerRemoved :: HasCallStack => FilePath -> IO () -testDelistedOwnerRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () +testDelistedOwnerRemoved ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -482,38 +537,45 @@ testDelistedOwnerRemoved tmp = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" -testNotDelistedMemberLeaves :: HasCallStack => FilePath -> IO () -testNotDelistedMemberLeaves tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () +testNotDelistedMemberLeaves ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath leaveGroup "privacy" cath bob <## "#privacy: cath left the group" (superUser "@SimpleX-Directory_1 privacy" + groupFoundN_ "_1" Nothing 2 cath "privacy" -testNotDelistedMemberRemoved :: HasCallStack => FilePath -> IO () -testNotDelistedMemberRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberRemoved :: HasCallStack => TestParams -> IO () +testNotDelistedMemberRemoved ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath removeMember "privacy" bob cath (superUser "@SimpleX-Directory_1 privacy" + groupFoundN_ "_1" Nothing 2 cath "privacy" -testDelistedServiceRemoved :: HasCallStack => FilePath -> IO () -testDelistedServiceRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedServiceRemoved :: HasCallStack => TestParams -> IO () +testDelistedServiceRemoved ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -524,13 +586,14 @@ testDelistedServiceRemoved tmp = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." - groupNotFound cath "privacy" + cath `connectVia` dsLink + groupNotFound_ "_1" cath "privacy" -testDelistedGroupDeleted :: HasCallStack => FilePath -> IO () -testDelistedGroupDeleted tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDelistedGroupDeleted :: HasCallStack => TestParams -> IO () +testDelistedGroupDeleted ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -550,18 +613,21 @@ testDelistedGroupDeleted tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group is deleted)." groupNotFound cath "privacy" -testDelistedRoleChanges :: HasCallStack => FilePath -> IO () -testDelistedRoleChanges tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedRoleChanges :: HasCallStack => TestParams -> IO () +testDelistedRoleChanges ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" cath <## "#privacy: bob changed the role of SimpleX-Directory from admin to member" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to member." bob <## "" @@ -570,7 +636,7 @@ testDelistedRoleChanges tmp = groupNotFound cath "privacy" -- re-listed if service role changed back without profile changes cath ##> "/mr privacy SimpleX-Directory admin" - cath <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + cath <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <## "#privacy: cath changed the role of SimpleX-Directory from member to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" @@ -579,7 +645,7 @@ testDelistedRoleChanges tmp = groupFoundN 3 cath "privacy" -- de-listed if owner role changed cath ##> "/mr privacy bob admin" - cath <## "#privacy: you changed the role of bob from owner to admin" + cath <## "#privacy: you changed the role of bob to admin" bob <## "#privacy: cath changed your role from owner to admin" bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to admin." bob <## "" @@ -588,7 +654,7 @@ testDelistedRoleChanges tmp = groupNotFound cath "privacy" -- re-listed if owner role changed back without profile changes cath ##> "/mr privacy bob owner" - cath <## "#privacy: you changed the role of bob from admin to owner" + cath <## "#privacy: you changed the role of bob to owner" bob <## "#privacy: cath changed your role from admin to owner" bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to owner." bob <## "" @@ -596,35 +662,38 @@ testDelistedRoleChanges tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (user role is set to owner)." groupFoundN 3 cath "privacy" -testNotDelistedMemberRoleChanged :: HasCallStack => FilePath -> IO () -testNotDelistedMemberRoleChanged tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberRoleChanged :: HasCallStack => TestParams -> IO () +testNotDelistedMemberRoleChanged ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" - bob <## "#privacy: you changed the role of cath from owner to member" + bob <## "#privacy: you changed the role of cath to member" cath <## "#privacy: bob changed your role from owner to member" groupFoundN 3 cath "privacy" -testNotSentApprovalBadRoles :: HasCallStack => FilePath -> IO () -testNotSentApprovalBadRoles tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotSentApprovalBadRoles :: HasCallStack => TestParams -> IO () +testNotSentApprovalBadRoles ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" updateProfileWithLink bob "privacy" welcomeWithLink 1 bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -633,11 +702,11 @@ testNotSentApprovalBadRoles tmp = approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -testNotApprovedBadRoles :: HasCallStack => FilePath -> IO () -testNotApprovedBadRoles tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotApprovedBadRoles :: HasCallStack => TestParams -> IO () +testNotApprovedBadRoles ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" @@ -645,14 +714,14 @@ testNotApprovedBadRoles tmp = updateProfileWithLink bob "privacy" welcomeWithLink 1 notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" let approve = "/approve 1:privacy 1" superUser #> ("@SimpleX-Directory " <> approve) superUser <# ("SimpleX-Directory> > " <> approve) superUser <## " Group is not approved: SimpleX-Directory is not an admin." groupNotFound cath "privacy" bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -660,11 +729,11 @@ testNotApprovedBadRoles tmp = approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -testRegOwnerChangedProfile :: HasCallStack => FilePath -> IO () -testRegOwnerChangedProfile tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testRegOwnerChangedProfile ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -674,35 +743,62 @@ testRegOwnerChangedProfile tmp = bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy:" cath <## "full name changed to: Privacy and Security" + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testAnotherOwnerChangedProfile :: HasCallStack => FilePath -> IO () -testAnotherOwnerChangedProfile tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testAnotherOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testAnotherOwnerChangedProfile ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" 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!" + 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" -testRegOwnerRemovedLink :: HasCallStack => FilePath -> IO () -testRegOwnerRemovedLink tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +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 -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -719,26 +815,33 @@ testRegOwnerRemovedLink tmp = cath <## "description changed to:" cath <## "Welcome!" superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + cath `connectVia` dsLink + 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" -testAnotherOwnerRemovedLink :: HasCallStack => FilePath -> IO () -testAnotherOwnerRemovedLink tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () +testAnotherOwnerRemovedLink ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" bob ##> "/show welcome #privacy" bob <## "Welcome message:" welcomeWithLink <- getTermLine bob @@ -748,7 +851,7 @@ testAnotherOwnerRemovedLink tmp = 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." @@ -759,25 +862,60 @@ testAnotherOwnerRemovedLink tmp = 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" -testDuplicateAskConfirmation :: HasCallStack => FilePath -> IO () -testDuplicateAskConfirmation tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +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 -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" _ <- groupAccepted bob "privacy" @@ -792,11 +930,11 @@ testDuplicateAskConfirmation tmp = completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2 groupFound bob "privacy" -testDuplicateProhibitRegistration :: HasCallStack => FilePath -> IO () -testDuplicateProhibitRegistration tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () +testDuplicateProhibitRegistration ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -804,11 +942,11 @@ testDuplicateProhibitRegistration tmp = _ <- submitGroup cath "privacy" "Privacy" cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." -testDuplicateProhibitConfirmation :: HasCallStack => FilePath -> IO () -testDuplicateProhibitConfirmation tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitConfirmation :: HasCallStack => TestParams -> IO () +testDuplicateProhibitConfirmation ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -823,11 +961,11 @@ testDuplicateProhibitConfirmation tmp = cath #> "@SimpleX-Directory /confirm 1:privacy" cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." -testDuplicateProhibitWhenUpdated :: HasCallStack => FilePath -> IO () -testDuplicateProhibitWhenUpdated tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitWhenUpdated :: HasCallStack => TestParams -> IO () +testDuplicateProhibitWhenUpdated ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -854,11 +992,11 @@ testDuplicateProhibitWhenUpdated tmp = groupFound bob "security" groupFound cath "security" -testDuplicateProhibitApproval :: HasCallStack => FilePath -> IO () -testDuplicateProhibitApproval tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitApproval :: HasCallStack => TestParams -> IO () +testDuplicateProhibitApproval ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -880,11 +1018,11 @@ testDuplicateProhibitApproval tmp = superUser <# ("SimpleX-Directory> > " <> approve) superUser <## " The group ID 2 (privacy) is already listed in the directory." -testListUserGroups :: HasCallStack => FilePath -> IO () -testListUserGroups tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testListUserGroups :: HasCallStack => TestParams -> IO () +testListUserGroups ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -892,22 +1030,15 @@ testListUserGroups tmp = fullAddMember "privacy" "Privacy" bob cath GRMember joinGroup "privacy" cath bob cath <## "#privacy: member SimpleX-Directory_1 is connected" - cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" 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" - cath <## "#anonymity: you changed the role of SimpleX-Directory from admin to member" + cath <## "#anonymity: you changed the role of SimpleX-Directory to member" cath <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (anonymity) is changed to member." cath <## "" cath <## "The group is no longer listed in the directory." @@ -915,53 +1046,133 @@ testListUserGroups tmp = groupNotFound cath "anonymity" listGroups superUser bob cath -testRestoreDirectory :: HasCallStack => FilePath -> IO () -testRestoreDirectory tmp = do - testListUserGroups tmp - restoreDirectoryService tmp 3 3 $ \superUser _dsLink -> - withTestChat tmp "bob" $ \bob -> - withTestChat tmp "cath" $ \cath -> do +testCapthaScreening :: HasCallStack => TestParams -> IO () +testCapthaScreening ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + -- check default role + bob #> "@SimpleX-Directory /role 1" + bob <# "SimpleX-Directory> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /role 1 observer to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + -- enable captcha + bob #> "@SimpleX-Directory /filter 1 captcha" + bob <# "SimpleX-Directory> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "Use /filter 1 [name] [captcha] to enable and /filter 1 off to disable filter." + -- connect with captcha screen + _ <- join cath groupLink + cath ##> "/_send #1(_support) text 123" -- sending incorrect captcha + cath <# "#privacy (support) 123" + cath <# "#privacy (support) SimpleX-Directory!> > cath 123" + cath <## " Incorrect text, please try again." + captcha <- dropStrPrefix "#privacy (support) SimpleX-Directory> " . dropTime <$> getTermLine cath + sendCaptcha cath captcha + cath <#. "#privacy SimpleX-Directory> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + cath #> "#privacy hello" + bob <# "#privacy cath> hello" + cath ##> "/l privacy" + cath <## "#privacy: you left the group" + cath <## "use /d #privacy to delete the group" + bob <## "#privacy: cath left the group" + cath ##> "/d #privacy" + cath <## "#privacy: you deleted the group" + -- change default role to observer + bob #> "@SimpleX-Directory /role 1 observer" + bob <# "SimpleX-Directory> > /role 1 observer" + bob <## " The initial member role for the group privacy is set to observer" + bob <## "" + bob <##. "Please note: it applies only to members joining via this link: https://" + -- connect with captcha screen again, as observer + captcha' <- join cath groupLink + sendCaptcha cath captcha' + -- message from cath that left + pastMember <- dropStrPrefix "#privacy: SimpleX-Directory forwarded a message from an unknown member, creating unknown member record " <$> getTermLine cath + cath <# ("#privacy " <> pastMember <> "> hello [>>]") + cath <#. "#privacy SimpleX-Directory> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: SimpleX-Directory added cath_1 (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath_1 is connected" + cath ##> "#privacy hello" + cath <## "#privacy: you don't have permission to send messages" + (bob "/ms privacy" + cath <## "cath (Catherine): observer, you, connected" + cath <## "SimpleX-Directory: admin, host, connected" + cath <## "bob (Bob): owner, connected" + cath <## (pastMember <> ": author, status unknown") + where + join cath groupLink = do + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) SimpleX-Directory> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + dropStrPrefix "#privacy (support) SimpleX-Directory> " . dropTime <$> getTermLine cath + sendCaptcha cath captcha = do + cath ##> ("/_send #1(_support) text " <> captcha) + cath <# ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) SimpleX-Directory!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + +testRestoreDirectory :: HasCallStack => TestParams -> IO () +testRestoreDirectory ps = do + testListUserGroups ps + restoreDirectoryService ps 3 3 $ \superUser _dsLink -> + withTestChat ps "bob" $ \bob -> + withTestChat ps "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" bob - <### [ "#privacy (Privacy): connected to server(s)", - "#security (Security): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#security: connected to server(s)" ] cath <## "2 contacts connected (use /cs for the list)" cath - <### [ "#privacy (Privacy): connected to server(s)", - "#anonymity (Anonymity): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#anonymity: connected to server(s)" ] listGroups superUser bob cath groupFoundN 3 bob "privacy" groupFound bob "security" groupFoundN 3 cath "privacy" cath #> "@SimpleX-Directory security" - cath <## "SimpleX-Directory: quantum resistant end-to-end encryption enabled" groupFoundN' 2 cath "security" +testCaptcha :: HasCallStack => TestParams -> IO () +testCaptcha _ps = do + let captcha = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrty" + matchCaptchaStr captcha captcha `shouldBe` True + matchCaptchaStr captcha "23456789ABcDEFGH1JKLMNoPQRsTuvwxYzabdefghijkmnpqrty" `shouldBe` True + matchCaptchaStr "23456789ABcDEFGH1JKLMNoPQRsTuvwxYzabdefghijkmnpqrty" captcha `shouldBe` True + matchCaptchaStr "OOIICPSUVWXZ" "OOIICPSUVWXZ" `shouldBe` True + matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwxz" `shouldBe` True + matchCaptchaStr "0o1lcpsuvwxz" "OOIICPSUVWXZ" `shouldBe` True + matchCaptchaStr "OOIICPSUVWXZ" "" `shouldBe` False + matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwx" `shouldBe` False + matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwxzz" `shouldBe` False + 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" @@ -969,34 +1180,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 @@ -1011,8 +1230,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 <## "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 @@ -1021,17 +1245,17 @@ addCathAsOwner bob cath = do joinGroup "privacy" cath bob cath <## "#privacy: member SimpleX-Directory is connected" -withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) -> IO () -withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg +withDirectoryService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO () +withDirectoryService ps = withDirectoryServiceCfg ps testCfg -withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg tmp cfg = withDirectoryServiceCfgOwnersGroup tmp cfg False +withDirectoryServiceCfg :: HasCallStack => TestParams -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfg ps cfg = withDirectoryServiceCfgOwnersGroup ps cfg False -withDirectoryServiceCfgOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do +withDirectoryServiceCfgOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup test = do dsLink <- - withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> - withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do + withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds -> + withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser when createOwnersGroup $ do superUser ##> "/g owners" @@ -1046,12 +1270,12 @@ withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do superUser <## "#owners: SimpleX-Directory joined the group" ds ##> "/ad" getContactLink ds True - withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test + withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test -restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () -restoreDirectoryService tmp ctCount grCount test = do +restoreDirectoryService :: HasCallStack => TestParams -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () +restoreDirectoryService ps ctCount grCount test = do dsLink <- - withTestChat tmp serviceDbPrefix $ \ds -> do + withTestChat ps serviceDbPrefix $ \ds -> do ds <## (show ctCount <> " contacts connected (use /cs for the list)") ds <## "Your address is active! To show: /sa" ds <## (show grCount <> " group links active") @@ -1060,16 +1284,16 @@ restoreDirectoryService tmp ctCount grCount test = do dsLink <- getContactLink ds False ds <## "auto_accept on" pure dsLink - withDirectory tmp testCfg dsLink test + withDirectory ps testCfg dsLink test -withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp cfg dsLink = withDirectoryOwnersGroup tmp cfg dsLink False +withDirectory :: HasCallStack => TestParams -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () +withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False -withDirectoryOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test = do - let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing +withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test = do + let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing runDirectory cfg opts $ - withTestChatCfg tmp cfg "super_user" $ \superUser -> do + withTestChatCfg ps cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" when createOwnersGroup $ superUser <## "#owners: connected to server(s)" @@ -1082,7 +1306,10 @@ runDirectory cfg opts@DirectoryOpts {directoryLog} action = do threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) where - bot st = simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts + bot st = do + env <- newServiceState opts + let cfg' = cfg {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} + simplexChatCore cfg' (mkChatOpts opts) $ directoryService st opts env registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 @@ -1153,15 +1380,20 @@ 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 <## "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 u ##> ("/c " <> dsLink) u <## "connection request sent!" - u <## "SimpleX-Directory: contact is connected" - u <# "SimpleX-Directory> Welcome to SimpleX-Directory service!" + u .<## ": contact is connected" + u .<# "> Welcome to SimpleX-Directory service!" u <## "Send a search string to find groups or /help to learn how to add groups to directory." u <## "" u <## "For example, send privacy to find groups about privacy." @@ -1205,19 +1437,22 @@ groupFoundN count u name = do groupFoundN' count u name groupFoundN' :: Int -> TestCC -> String -> IO () -groupFoundN' = groupFoundN_ Nothing +groupFoundN' = groupFoundN_ "" Nothing -groupFoundN_ :: Maybe Int -> Int -> TestCC -> String -> IO () -groupFoundN_ shownId_ count u name = do - u <# ("SimpleX-Directory> > " <> name) +groupFoundN_ :: String -> Maybe Int -> Int -> TestCC -> String -> IO () +groupFoundN_ suffix shownId_ count u name = do + u <# ("SimpleX-Directory" <> suffix <> "> > " <> name) u <## " Found 1 group(s)." - u <#. ("SimpleX-Directory> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) + u <#. ("SimpleX-Directory" <> suffix <> "> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") groupNotFound :: TestCC -> String -> IO () -groupNotFound u s = do - u #> ("@SimpleX-Directory " <> s) - u <# ("SimpleX-Directory> > " <> s) +groupNotFound = groupNotFound_ "" + +groupNotFound_ :: String -> TestCC -> String -> IO () +groupNotFound_ suffix u s = do + u #> ("@SimpleX-Directory" <> suffix <> " " <> s) + u <# ("SimpleX-Directory" <> suffix <> "> > " <> s) u <## " No groups found" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 32af1d5c95..ce555c676c 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -12,10 +12,12 @@ module ChatClient where +import ChatTests.DBUtils import Control.Concurrent (forkIOWithUnmask, killThread, threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, bracket_) +import Control.Logger.Simple (LogLevel (..)) import Control.Monad import Control.Monad.Except import Control.Monad.Reader @@ -53,8 +55,8 @@ import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (srvHostnamesSMPClientVersion) import Simplex.Messaging.Server (runSMPServerBlocking) -import Simplex.Messaging.Server.Env.STM -import Simplex.Messaging.Server.MsgStore.Types (AMSType (..), SMSType (..)) +import Simplex.Messaging.Server.Env.STM (AServerStoreCfg (..), ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) +import Simplex.Messaging.Server.MsgStore.Types (SQSType (..), SMSType (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransportServerConfig) import Simplex.Messaging.Version @@ -68,6 +70,9 @@ import Test.Hspec (Expectation, HasCallStack, shouldReturn) import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) #else import Data.ByteArray (ScrubbedBytes) +import qualified Data.Map.Strict as M +import Simplex.Messaging.Agent.Client (agentClientStore) +import Simplex.Messaging.Agent.Store.Common (withConnection) import System.FilePath (()) #endif @@ -90,7 +95,6 @@ testOpts :: ChatOpts testOpts = ChatOpts { coreOptions = testCoreOpts, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, @@ -114,10 +118,13 @@ 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", + trackQueries = DB.TQAll, vacuumOnMigration = True #endif }, @@ -130,6 +137,7 @@ testCoreOpts = logAgent = Nothing, logFile = Nothing, tbqSize = 16, + deviceName = Nothing, highlyAvailable = False, yesToUpMigrations = False } @@ -179,6 +187,7 @@ testCfg = defaultChatConfig { agentConfig = testAgentCfg, showReceipts = False, + shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16 } @@ -249,117 +258,111 @@ prevVersion (Version v) = Version (v - 1) nextVersion :: Version v -> Version v nextVersion (Version v) = Version (v + 1) -testCfgCreateGroupDirect :: ChatConfig -testCfgCreateGroupDirect = - mkCfgCreateGroupDirect testCfg - -mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig -mkCfgCreateGroupDirect cfg = - cfg - { chatVRange = groupCreateDirectVRange, - agentConfig = testAgentCfgSlow - } - -groupCreateDirectVRange :: VersionRangeChat -groupCreateDirectVRange = mkVersionRange (VersionChat 1) (VersionChat 1) - -testCfgGroupLinkViaContact :: ChatConfig -testCfgGroupLinkViaContact = - mkCfgGroupLinkViaContact testCfg - -mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig -mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} - -groupLinkViaContactVRange :: VersionRangeChat -groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) - -createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix profile = do - Right db@ChatDatabase {chatStore, agentStore} <- createDatabase tmp coreOptions dbPrefix +createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC +createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do + Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True - startTestChat_ db cfg opts user + startTestChat_ ps db cfg opts user -startTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> IO TestCC -startTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix = do - Right db@ChatDatabase {chatStore} <- createDatabase tmp coreOptions dbPrefix +startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC +startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do + Right db@ChatDatabase {chatStore} <- createDatabase ps coreOptions dbPrefix Just user <- find activeUser <$> withTransaction chatStore getUsers - startTestChat_ db cfg opts user + startTestChat_ ps db cfg opts user -createDatabase :: FilePath -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) +createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) #if defined(dbPostgres) -createDatabase _tmp CoreChatOpts {dbOptions} dbPrefix = do +createDatabase _params CoreChatOpts {dbOptions} dbPrefix = do createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} MCError insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") #else -createDatabase tmp CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbFilePrefix = tmp dbPrefix} MCError +createDatabase TestParams {tmpPath} CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbFilePrefix = tmpPath dbPrefix} MCError insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") #endif -startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC -startTestChat_ db cfg opts user = do +startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC +startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {maintenance} user = do t <- withVirtualTerminal termSettings pure 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 opts) $ 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 = False} + pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput} -stopTestChat :: TestCC -> IO () -stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do +stopTestChat :: TestParams -> TestCC -> IO () +stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do stopChatController cc uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent +#if !defined(dbPostgres) + chatStats <- withConnection chatStore $ readTVarIO . DB.slow + atomically $ modifyTVar' (chatQueryStats ps) $ M.unionWith combineStats chatStats + agentStats <- withConnection (agentClientStore smpAgent) $ readTVarIO . DB.slow + atomically $ modifyTVar' (agentQueryStats ps) $ M.unionWith combineStats agentStats +#endif closeDBStore chatStore threadDelay 200000 +#if !defined(dbPostgres) + where + combineStats + DB.SlowQueryStats {count, timeMax, timeAvg, errs} + DB.SlowQueryStats {count = count', timeMax = timeMax', timeAvg = timeAvg', errs = errs'} = + DB.SlowQueryStats + { count = count + count', + timeMax = max timeMax timeMax', + timeAvg = (timeAvg * count + timeAvg' * count') `div` (count + count'), + errs = M.unionWith (+) errs errs' + } +#endif -withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChat tmp = withNewTestChatCfgOpts tmp testCfg testOpts +withNewTestChat :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChat ps = withNewTestChatCfgOpts ps testCfg testOpts -withNewTestChatV1 :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatV1 tmp = withNewTestChatCfg tmp testCfgV1 +withNewTestChatV1 :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatV1 ps = withNewTestChatCfg ps testCfgV1 -withNewTestChatCfg :: HasCallStack => FilePath -> ChatConfig -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatCfg tmp cfg = withNewTestChatCfgOpts tmp cfg testOpts +withNewTestChatCfg :: HasCallStack => TestParams -> ChatConfig -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatCfg ps cfg = withNewTestChatCfgOpts ps cfg testOpts -withNewTestChatOpts :: HasCallStack => FilePath -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatOpts tmp = withNewTestChatCfgOpts tmp testCfg +withNewTestChatOpts :: HasCallStack => TestParams -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatOpts ps = withNewTestChatCfgOpts ps testCfg -withNewTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatCfgOpts tmp cfg opts dbPrefix profile runTest = +withNewTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatCfgOpts ps cfg opts dbPrefix profile runTest = bracket - (createTestChat tmp cfg opts dbPrefix profile) - stopTestChat + (createTestChat ps cfg opts dbPrefix profile) + (stopTestChat ps) (\cc -> runTest cc >>= ((cc )) -withTestChatV1 :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatV1 tmp = withTestChatCfg tmp testCfgV1 +withTestChatV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatV1 ps = withTestChatCfg ps testCfgV1 -withTestChat :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChat tmp = withTestChatCfgOpts tmp testCfg testOpts +withTestChat :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChat ps = withTestChatCfgOpts ps testCfg testOpts -withTestChatCfg :: HasCallStack => FilePath -> ChatConfig -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatCfg tmp cfg = withTestChatCfgOpts tmp cfg testOpts +withTestChatCfg :: HasCallStack => TestParams -> ChatConfig -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatCfg ps cfg = withTestChatCfgOpts ps cfg testOpts -withTestChatOpts :: HasCallStack => FilePath -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg +withTestChatOpts :: HasCallStack => TestParams -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatOpts ps = withTestChatCfgOpts ps testCfg -withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +withTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatCfgOpts ps cfg opts dbPrefix = bracket (startTestChat ps cfg opts dbPrefix) (\cc -> cc > stopTestChat ps cc) --- enable output for specific chat controller, use like this: --- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... -withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a -withTestOutput cc runTest = runTest cc {printOutput = True} +-- enable output for specific test. +-- usage: withTestOutput $ testChat2 aliceProfile bobProfile $ \alice bob -> do ... +withTestOutput :: HasCallStack => (HasCallStack => TestParams -> IO ()) -> TestParams -> IO () +withTestOutput test ps = test ps {printOutput = True} readTerminalOutput :: VirtualTerminal -> TQueue String -> IO () readTerminalOutput t termQ = do @@ -390,27 +393,27 @@ withTmpFiles = (createDirectoryIfMissing False "tests/tmp") (removeDirectoryRecursive "tests/tmp") -testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> FilePath -> IO () -testChatN cfg opts ps test tmp = do - tcs <- getTestCCs (zip ps [1 ..]) [] - test tcs - concurrentlyN_ $ map ( ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> TestParams -> IO () +testChatN cfg opts ps test params = + bracket (getTestCCs $ zip ps [1 ..]) endTests test where - getTestCCs :: [(Profile, Int)] -> [TestCC] -> IO [TestCC] - getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat tmp cfg opts (show db) p <*> getTestCCs envs' tcs + getTestCCs :: [(Profile, Int)] -> IO [TestCC] + getTestCCs [] = pure [] + getTestCCs ((p, db) : envs') = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' + endTests tcs = do + mapConcurrently_ ( TestCC -> Int -> Expectation ( TestCC -> IO String -getTermLine cc = +getTermLine cc@TestCC {printOutput} = 5000000 `timeout` atomically (readTQueue $ termQ cc) >>= \case Just s -> do -- remove condition to always echo virtual terminal -- when True $ do - when (printOutput cc) $ do + when printOutput $ do name <- userName cc putStrLn $ name <> ": " <> s pure s @@ -420,55 +423,71 @@ userName :: TestCC -> IO [Char] userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser -testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> TestParams -> IO () testChat = testChatCfgOpts testCfg testOpts -testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts cfg opts p test = testChatN cfg opts [p] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc] = test tc test_ _ = error "expected 1 chat client" -testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts -testChatCfg2 :: HasCallStack => ChatConfig -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg2 :: HasCallStack => ChatConfig -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg2 cfg = testChatCfgOpts2 cfg testOpts -testChatOpts2 :: HasCallStack => ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatOpts2 :: HasCallStack => ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatOpts2 = testChatCfgOpts2 testCfg -testChatCfgOpts2 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts2 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts2 cfg opts p1 p2 test = testChatN cfg opts [p1, p2] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2] = test tc1 tc2 test_ _ = error "expected 2 chat clients" -testChat3 :: HasCallStack => Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat3 :: HasCallStack => Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat3 = testChatCfgOpts3 testCfg testOpts -testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg3 cfg = testChatCfgOpts3 cfg testOpts -testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3] = test tc1 tc2 tc3 test_ _ = error "expected 3 chat clients" -testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () -testChat4 = testChatCfg4 testCfg +testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChat4 = testChatCfgOpts4 testCfg testOpts -testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () -testChatCfg4 cfg p1 p2 p3 p4 test = testChatN cfg testOpts [p1, p2, p3, p4] test_ +testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatCfg4 cfg = testChatCfgOpts4 cfg testOpts + +testChatOpts4 :: HasCallStack => ChatOpts -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatOpts4 = testChatCfgOpts4 testCfg + +testChatCfgOpts4 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatCfgOpts4 cfg opts p1 p2 p3 p4 test = testChatN cfg opts [p1, p2, p3, p4] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3, tc4] = test tc1 tc2 tc3 tc4 test_ _ = error "expected 4 chat clients" +testChat5 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChat5 = testChatCfg5 testCfg + +testChatCfg5 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatCfg5 cfg p1 p2 p3 p4 p5 test = testChatN cfg testOpts [p1, p2, p3, p4, p5] test_ + where + test_ :: HasCallStack => [TestCC] -> IO () + test_ [tc1, tc2, tc3, tc4, tc5] = test tc1 tc2 tc3 tc4 tc5 + test_ _ = error "expected 5 chat clients" + concurrentlyN_ :: [IO a] -> IO () concurrentlyN_ = mapConcurrently_ id @@ -477,14 +496,12 @@ smpServerCfg = ServerConfig { transports = [(serverPort, transport @TLS, False)], tbqSize = 1, - msgStoreType = AMSType SMSMemory, msgQueueQuota = 16, maxJournalMsgCount = 24, maxJournalStateLines = 4, - queueIdBytes = 12, + queueIdBytes = 24, msgIdBytes = 6, - storeLogFile = Nothing, - storeMsgsFile = Nothing, + serverStoreCfg = ASSCfg SQSMemory SMSMemory $ SSCMemory Nothing, storeNtfsFile = Nothing, allowNewQueues = True, -- server password is disabled as otherwise v1 tests fail @@ -518,9 +535,13 @@ smpServerCfg = smpAgentCfg = defaultSMPClientAgentConfig, allowSMPProxy = True, serverClientConcurrency = 16, - information = Nothing + information = Nothing, + startOptions = StartOptions {maintenance = False, compactLog = False, logLevel = LogError, skipWarnings = False, confirmMigrations = MCYesUp} } +persistentServerStoreCfg :: FilePath -> AServerStoreCfg +persistentServerStoreCfg tmp = ASSCfg SQSMemory SMSMemory $ SSCMemory $ Just StorePaths {storeLogFile = tmp <> "/smp-server-store.log", storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"} + withSmpServer :: IO () -> IO () withSmpServer = withSmpServer' smpServerCfg diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index e8f3838eb6..20fccf6c64 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,6 +1,7 @@ module ChatTests where import ChatTests.ChatList +import ChatTests.DBUtils import ChatTests.Direct import ChatTests.Files import ChatTests.Forward @@ -9,7 +10,7 @@ import ChatTests.Local import ChatTests.Profiles import Test.Hspec hiding (it) -chatTests :: SpecWith FilePath +chatTests :: SpecWith TestParams chatTests = do describe "direct tests" chatDirectTests describe "forward tests" chatForwardTests diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index dd88aac54e..dcd18a9818 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -1,12 +1,13 @@ module ChatTests.ChatList where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Data.Time.Clock (getCurrentTime) import Data.Time.Format.ISO8601 (iso8601Show) import Test.Hspec hiding (it) -chatListTests :: SpecWith FilePath +chatListTests :: SpecWith TestParams chatListTests = do it "get last chats" testPaginationLast it "get chats before/after timestamp" testPaginationTs @@ -16,7 +17,7 @@ chatListTests = do it "filter favorite or unread" testFilterFavoriteOrUnread it "sort and filter chats of all types" testPaginationAllChatTypes -testPaginationLast :: HasCallStack => FilePath -> IO () +testPaginationLast :: HasCallStack => TestParams -> IO () testPaginationLast = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -32,7 +33,7 @@ testPaginationLast = alice <# "bob> hey" alice <# "@cath hey" -testPaginationTs :: HasCallStack => FilePath -> IO () +testPaginationTs :: HasCallStack => TestParams -> IO () testPaginationTs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -59,7 +60,7 @@ getChats_ :: HasCallStack => TestCC -> String -> [(String, String)] -> Expectati getChats_ cc query expected = do cc #$> ("/_get chats 1 pcc=on " <> query, chats, expected) -testFilterSearch :: HasCallStack => FilePath -> IO () +testFilterSearch :: HasCallStack => TestParams -> IO () testFilterSearch = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -75,7 +76,7 @@ testFilterSearch = getChats_ alice (query "bob") [("@bob", "hey")] getChats_ alice (query "Bob") [("@bob", "hey")] -testFilterFavorite :: HasCallStack => FilePath -> IO () +testFilterFavorite :: HasCallStack => TestParams -> IO () testFilterFavorite = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -99,7 +100,7 @@ testFilterFavorite = alice <## "ok" getChats_ alice query [("@bob", "hey")] -testFilterUnread :: HasCallStack => FilePath -> IO () +testFilterUnread :: HasCallStack => TestParams -> IO () testFilterUnread = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -123,7 +124,7 @@ testFilterUnread = alice <## "ok" getChats_ alice query [("@bob", "hey")] -testFilterFavoriteOrUnread :: HasCallStack => FilePath -> IO () +testFilterFavoriteOrUnread :: HasCallStack => TestParams -> IO () testFilterFavoriteOrUnread = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -154,7 +155,7 @@ testFilterFavoriteOrUnread = alice <## "ok" getChats_ alice query [("@cath", "hey"), ("@bob", "hey")] -testPaginationAllChatTypes :: HasCallStack => FilePath -> IO () +testPaginationAllChatTypes :: HasCallStack => TestParams -> IO () testPaginationAllChatTypes = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do diff --git a/tests/ChatTests/DBUtils.hs b/tests/ChatTests/DBUtils.hs new file mode 100644 index 0000000000..f6ac2284da --- /dev/null +++ b/tests/ChatTests/DBUtils.hs @@ -0,0 +1,15 @@ +{-# LANGUAGE CPP #-} + +module ChatTests.DBUtils + +#if defined(dbPostgres) + ( module ChatTests.DBUtils.Postgres, + ) + where +import ChatTests.DBUtils.Postgres +#else + ( module ChatTests.DBUtils.SQLite, + ) + where +import ChatTests.DBUtils.SQLite +#endif diff --git a/tests/ChatTests/DBUtils/Postgres.hs b/tests/ChatTests/DBUtils/Postgres.hs new file mode 100644 index 0000000000..2b160379bb --- /dev/null +++ b/tests/ChatTests/DBUtils/Postgres.hs @@ -0,0 +1,6 @@ +module ChatTests.DBUtils.Postgres where + +data TestParams = TestParams + { tmpPath :: FilePath, + printOutput :: Bool + } diff --git a/tests/ChatTests/DBUtils/SQLite.hs b/tests/ChatTests/DBUtils/SQLite.hs new file mode 100644 index 0000000000..2de94882cd --- /dev/null +++ b/tests/ChatTests/DBUtils/SQLite.hs @@ -0,0 +1,12 @@ +module ChatTests.DBUtils.SQLite where + +import Database.SQLite.Simple (Query) +import Simplex.Messaging.Agent.Store.SQLite.DB +import Simplex.Messaging.TMap (TMap) + +data TestParams = TestParams + { tmpPath :: FilePath, + printOutput :: Bool, + chatQueryStats :: TMap Query SlowQueryStats, + agentQueryStats :: TMap Query SlowQueryStats + } diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index bc857132eb..21ee1e9218 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -12,6 +12,7 @@ module ChatTests.Direct where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -28,7 +29,6 @@ import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite @@ -45,10 +45,11 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Options.DB import System.FilePath (()) #endif -chatDirectTests :: SpecWith FilePath +chatDirectTests :: SpecWith TestParams chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive messages" testAddContact @@ -59,7 +60,6 @@ chatDirectTests = do it "deleting contact deletes profile" testDeleteContactDeletesProfile it "delete contact keeping conversation" testDeleteContactKeepConversation it "delete conversation keeping contact" testDeleteConversationKeepContact - it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message edit history" testDirectMessageEditHistory @@ -180,7 +180,7 @@ chatDirectTests = do testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 -testAddContact :: HasCallStack => SpecWith FilePath +testAddContact :: HasCallStack => SpecWith TestParams testAddContact = versionTestMatrix2 runTestAddContact where runTestAddContact pqExpected alice bob = do @@ -237,9 +237,10 @@ testAddContact = versionTestMatrix2 runTestAddContact then chatFeatures else (0, e2eeInfoNoPQStr) : tail chatFeatures -testRetryConnecting :: HasCallStack => FilePath -> IO () -testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test tmp +testRetryConnecting :: HasCallStack => TestParams -> IO () +testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps where + tmp = tmpPath ps test alice bob = do inv <- withSmpServer' serverCfg' $ do alice ##> "/_connect 1" @@ -269,8 +270,7 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te smpServerCfg { transports = [("7003", transport @TLS, False)], msgQueueQuota = 2, - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests cfg' = @@ -289,14 +289,14 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te } } -testRetryConnectingClientTimeout :: HasCallStack => FilePath -> IO () -testRetryConnectingClientTimeout tmp = do +testRetryConnectingClientTimeout :: HasCallStack => TestParams -> IO () +testRetryConnectingClientTimeout ps = do inv <- withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp cfg' opts' "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> do alice ##> "/_connect 1" inv <- getInvitation alice - withNewTestChatCfgOpts tmp cfgZeroTimeout opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfgZeroTimeout opts' "bob" bobProfile $ \bob -> do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -308,8 +308,8 @@ testRetryConnectingClientTimeout tmp = do logFile `shouldContain` "SECURE" withSmpServer' serverCfg' $ do - withTestChatCfgOpts tmp cfg' opts' "alice" $ \alice -> do - withTestChatCfgOpts tmp cfg' opts' "bob" $ \bob -> do + withTestChatCfgOpts ps cfg' opts' "alice" $ \alice -> do + withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -323,12 +323,12 @@ testRetryConnectingClientTimeout tmp = do bob #> "@alice message 2" alice <# "bob> message 2" where + tmp = tmpPath ps serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], msgQueueQuota = 2, - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests cfg' = @@ -358,7 +358,7 @@ testRetryConnectingClientTimeout tmp = do } } -testMarkReadDirect :: HasCallStack => FilePath -> IO () +testMarkReadDirect :: HasCallStack => TestParams -> IO () testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice #> "@bob 1" @@ -372,9 +372,9 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do bob ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine bob let itemIds = intercalate "," $ map show [i - 3 .. i] - bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") + bob #$> ("/_read chat items @2 " <> itemIds, id, "items read for chat") -testChatPaginationInitial :: HasCallStack => FilePath -> IO () +testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob -- Wait, otherwise ids are going to be wrong. @@ -391,7 +391,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map itemId [1 .. 2] - bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") + bob #$> ("/_read chat items @2 " <> itemIds, id, "items read for chat") bob #$> ("/_get chat @2 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) -- Read all items @@ -404,7 +404,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice { markRead = False } -testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsSeparate :: HasCallStack => TestParams -> IO () testDuplicateContactsSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -430,7 +430,7 @@ testDuplicateContactsSeparate = bob @@@ [("@alice", "hey"), ("@alice_1", "2")] bob `hasContactProfiles` ["bob", "alice", "alice"] -testDuplicateContactsMultipleSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsMultipleSeparate :: HasCallStack => TestParams -> IO () testDuplicateContactsMultipleSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -470,7 +470,7 @@ testDuplicateContactsMultipleSeparate = alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] -testPlanInvitationLinkOk :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOk :: HasCallStack => TestParams -> IO () testPlanInvitationLinkOk = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -489,9 +489,9 @@ testPlanInvitationLinkOk = bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection alice <##> bob -testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () -testPlanInvitationLinkOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanInvitationLinkOwn :: HasCallStack => TestParams -> IO () +testPlanInvitationLinkOwn ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/c" inv <- getInvitation alice alice ##> ("/_connect plan 1 " <> inv) @@ -524,12 +524,12 @@ testPlanInvitationLinkOwn tmp = ] alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] -testPlanInvitationLinkConnecting :: HasCallStack => FilePath -> IO () -testPlanInvitationLinkConnecting tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanInvitationLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanInvitationLinkConnecting ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -540,7 +540,7 @@ testPlanInvitationLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> invSchema2) bob <## "invitation link: connecting" -testContactClear :: HasCallStack => FilePath -> IO () +testContactClear :: HasCallStack => TestParams -> IO () testContactClear = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -552,7 +552,7 @@ testContactClear = bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") bob #$> ("/_get chat @2 count=100", chat, []) -testDeleteContactDeletesProfile :: HasCallStack => FilePath -> IO () +testDeleteContactDeletesProfile :: HasCallStack => TestParams -> IO () testDeleteContactDeletesProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -572,7 +572,7 @@ testDeleteContactDeletesProfile = (bob FilePath -> IO () +testDeleteContactKeepConversation :: HasCallStack => TestParams -> IO () testDeleteContactKeepConversation = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -590,7 +590,7 @@ testDeleteContactKeepConversation = bob ##> "@alice hey" bob <## "alice: not ready" -testDeleteConversationKeepContact :: HasCallStack => FilePath -> IO () +testDeleteConversationKeepContact :: HasCallStack => TestParams -> IO () testDeleteConversationKeepContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -609,43 +609,7 @@ testDeleteConversationKeepContact = alice @@@ [("@bob", "hi")] alice <##> bob -testDeleteUnusedContactSilent :: HasCallStack => FilePath -> IO () -testDeleteUnusedContactSilent = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] - bob `hasContactProfiles` ["bob", "alice", "cath"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - -- bob deletes cath, cath's bob contact is deleted silently - bob ##> "/d cath" - bob <## "cath: contact is deleted" - bob ##> "/contacts" - bob <## "alice (Alice)" - threadDelay 50000 - cath ##> "/contacts" - cath <## "alice (Alice)" - -- group messages work - alice #> "#team hello" - concurrentlyN_ - [ bob <# "#team alice> hello", - cath <# "#team alice> hello" - ] - bob #> "#team hi there" - concurrentlyN_ - [ alice <# "#team bob> hi there", - cath <# "#team bob> hi there" - ] - cath #> "#team hey" - concurrentlyN_ - [ alice <# "#team cath> hey", - bob <# "#team cath> hey" - ] - -testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO () +testDirectMessageQuotedReply :: HasCallStack => TestParams -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -670,7 +634,7 @@ testDirectMessageQuotedReply = bob #$> ("/_get chat @2 count=1", chat', [((1, "will tell more"), Just (1, "all good - you?"))]) alice #$> ("/_get chat @2 count=1", chat', [((0, "will tell more"), Just (0, "all good - you?"))]) -testDirectMessageUpdate :: HasCallStack => FilePath -> IO () +testDirectMessageUpdate :: HasCallStack => TestParams -> IO () testDirectMessageUpdate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -734,7 +698,7 @@ testDirectMessageUpdate = alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((1, "greetings 🤝"), Nothing), ((0, "hey Alice"), Just (1, "hello 🙂")), ((0, "greetings Alice"), Just (1, "hey 👋"))]) bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "greetings 🤝"), Nothing), ((1, "hey Alice"), Just (0, "hello 🙂")), ((1, "greetings Alice"), Just (0, "hey 👋"))]) -testDirectMessageEditHistory :: HasCallStack => FilePath -> IO () +testDirectMessageEditHistory :: HasCallStack => TestParams -> IO () testDirectMessageEditHistory = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -805,7 +769,7 @@ testDirectMessageEditHistory = bob <## "message history:" bob .<## ": hey there" -testDirectMessageDelete :: HasCallStack => FilePath -> IO () +testDirectMessageDelete :: HasCallStack => TestParams -> IO () testDirectMessageDelete = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -886,7 +850,7 @@ testDirectMessageDelete = bob #$> ("/_delete item @2 " <> itemId 4 <> " internal", id, "message deleted") bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "hello 🙂"), Nothing), ((1, "do you receive my messages?"), Just (0, "hello 🙂"))]) -testDirectMessageDeleteMultiple :: HasCallStack => FilePath -> IO () +testDirectMessageDeleteMultiple :: HasCallStack => TestParams -> IO () testDirectMessageDeleteMultiple = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -908,7 +872,7 @@ testDirectMessageDeleteMultiple = alice #$> ("/_get chat @2 count=2", chat, [(1, "hello [marked deleted]"), (1, "hey [marked deleted]")]) bob #$> ("/_get chat @2 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) -testDirectMessageDeleteMultipleManyBatches :: HasCallStack => FilePath -> IO () +testDirectMessageDeleteMultipleManyBatches :: HasCallStack => TestParams -> IO () testDirectMessageDeleteMultipleManyBatches = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -937,7 +901,7 @@ testDirectMessageDeleteMultipleManyBatches = forM_ [(1 :: Int) .. 300] $ \i -> do bob <# ("alice> [marked deleted] message " <> show i) -testDirectLiveMessage :: HasCallStack => FilePath -> IO () +testDirectLiveMessage :: HasCallStack => TestParams -> IO () testDirectLiveMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -966,7 +930,7 @@ testDirectLiveMessage = bob .<## ": hello 2" bob .<## ":" -testDirectTimedMessage :: HasCallStack => FilePath -> IO () +testDirectTimedMessage :: HasCallStack => TestParams -> IO () testDirectTimedMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -992,7 +956,7 @@ testDirectTimedMessage = alice <# "@bob hi" bob <# "alice> hi" -testRepeatAuthErrorsDisableContact :: HasCallStack => FilePath -> IO () +testRepeatAuthErrorsDisableContact :: HasCallStack => TestParams -> IO () testRepeatAuthErrorsDisableContact = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1012,7 +976,7 @@ testRepeatAuthErrorsDisableContact = alice #> "@bob hey" alice <## "[bob, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" -testMultilineMessage :: HasCallStack => FilePath -> IO () +testMultilineMessage :: HasCallStack => TestParams -> IO () testMultilineMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers alice cath @@ -1029,7 +993,7 @@ testMultilineMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bo cath <# "alice> hello" cath <## "there" -testLargeMessage :: HasCallStack => FilePath -> IO () +testLargeMessage :: HasCallStack => TestParams -> IO () testLargeMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1043,7 +1007,7 @@ testLargeMessage = bob <## "contact alice changed to alice2" bob <## "use @alice2 to send messages" -testSendMulti :: HasCallStack => FilePath -> IO () +testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1055,7 +1019,7 @@ testSendMulti = bob <# "alice> test 1" bob <# "alice> test 2" -testSendMultiTimed :: HasCallStack => FilePath -> IO () +testSendMultiTimed :: HasCallStack => TestParams -> IO () testSendMultiTimed = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1076,7 +1040,7 @@ testSendMultiTimed = "timed message deleted: test 2" ] -testSendMultiWithQuote :: HasCallStack => FilePath -> IO () +testSendMultiWithQuote :: HasCallStack => TestParams -> IO () testSendMultiWithQuote = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1119,7 +1083,7 @@ testSendMultiWithQuote = bob <## " message 3" bob <# "alice> message 1" -testSendMultiManyBatches :: HasCallStack => FilePath -> IO () +testSendMultiManyBatches :: HasCallStack => TestParams -> IO () testSendMultiManyBatches = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1149,7 +1113,9 @@ testSendMultiManyBatches = DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdBob) :: IO [[Int]] bobItemsCount `shouldBe` [[300]] -testGetSetSMPServers :: HasCallStack => FilePath -> IO () + threadDelay 1000000 + +testGetSetSMPServers :: HasCallStack => TestParams -> IO () testGetSetSMPServers = testChat aliceProfile $ \alice -> do @@ -1177,7 +1143,7 @@ testGetSetSMPServers = alice <## " smp://2345-w==@smp2.example.im" alice <## " smp://3456-w==@smp3.example.im:5224" -testTestSMPServerConnection :: HasCallStack => FilePath -> IO () +testTestSMPServerConnection :: HasCallStack => TestParams -> IO () testTestSMPServerConnection = testChat aliceProfile $ \alice -> do @@ -1192,7 +1158,7 @@ testTestSMPServerConnection = alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in SMP server address is incorrect" -testGetSetXFTPServers :: HasCallStack => FilePath -> IO () +testGetSetXFTPServers :: HasCallStack => TestParams -> IO () testGetSetXFTPServers = testChat aliceProfile $ \alice -> withXFTPServer $ do @@ -1219,7 +1185,7 @@ testGetSetXFTPServers = alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" -testTestXFTPServer :: HasCallStack => FilePath -> IO () +testTestXFTPServer :: HasCallStack => TestParams -> IO () testTestXFTPServer = testChat aliceProfile $ \alice -> withXFTPServer $ do @@ -1234,7 +1200,7 @@ testTestXFTPServer = alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect" -testOperators :: HasCallStack => FilePath -> IO () +testOperators :: HasCallStack => TestParams -> IO () testOperators = testChatCfgOpts testCfg opts' aliceProfile $ \alice -> do @@ -1264,59 +1230,59 @@ testOperators = where opts' = testOpts {coreOptions = testCoreOpts {smpServers = [], xftpServers = []}} -testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testAsyncInitiatingOffline aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testAsyncInitiatingOffline aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp aliceCfg "alice" $ \alice -> do + withTestChatCfg ps aliceCfg "alice" $ \alice -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testAsyncAcceptingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testAsyncAcceptingOffline aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testAsyncAcceptingOffline :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testAsyncAcceptingOffline aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp aliceCfg "alice" $ \alice -> do - withTestChatCfg tmp bobCfg "bob" $ \bob -> do + withTestChatCfg ps aliceCfg "alice" $ \alice -> do + withTestChatCfg ps bobCfg "bob" $ \bob -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testFullAsyncFast :: HasCallStack => FilePath -> IO () -testFullAsyncFast tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testFullAsyncFast :: HasCallStack => TestParams -> IO () +testFullAsyncFast ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" threadDelay 250000 - withTestChat tmp "alice" $ \alice -> + withTestChat ps "alice" $ \alice -> alice <## "bob (Bob): contact is connected" - withTestChat tmp "bob" $ \bob -> + withTestChat ps "bob" $ \bob -> bob <## "alice (Alice): contact is connected" -testFullAsyncSlow :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testFullAsyncSlow aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testFullAsyncSlow :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testFullAsyncSlow aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -1329,8 +1295,8 @@ testFullAsyncSlow aliceCfg bobCfg tmp = do bob <## "1 contacts connected (use /cs for the list)" bob <## "alice (Alice): contact is connected" where - withAlice = withTestChatCfg tmp aliceCfg "alice" - withBob = withTestChatCfg tmp aliceCfg "bob" + withAlice = withTestChatCfg ps aliceCfg "alice" + withBob = withTestChatCfg ps aliceCfg "bob" testCallType :: CallType testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encryption = True}} @@ -1355,7 +1321,7 @@ serialize = B.unpack . LB.toStrict . J.encode repeatM_ :: Int -> IO a -> IO () repeatM_ n a = forM_ [1 .. n] $ const a -testNegotiateCall :: HasCallStack => FilePath -> IO () +testNegotiateCall :: HasCallStack => TestParams -> IO () testNegotiateCall = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1402,10 +1368,10 @@ testNegotiateCall = alice <## "call with bob ended" alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: ended (00:00)")]) -testMaintenanceMode :: HasCallStack => FilePath -> IO () -testMaintenanceMode tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testMaintenanceMode :: HasCallStack => TestParams -> IO () +testMaintenanceMode ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/c" alice <## "error: chat not started" alice ##> "/_start" @@ -1437,7 +1403,7 @@ testMaintenanceMode tmp = do alice ##> "/_start" alice <## "error: chat store changed, please restart chat" -- works after full restart - withTestChat tmp "alice" $ \alice -> testChatWorking alice bob + withTestChat ps "alice" $ \alice -> testChatWorking alice bob testChatWorking :: HasCallStack => TestCC -> TestCC -> IO () testChatWorking alice bob = do @@ -1447,10 +1413,10 @@ testChatWorking alice bob = do bob #> "@alice hello too" alice <# "bob> hello too" -testMaintenanceModeWithFiles :: HasCallStack => FilePath -> IO () -testMaintenanceModeWithFiles tmp = withXFTPServer $ do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testMaintenanceModeWithFiles :: HasCallStack => TestParams -> IO () +testMaintenanceModeWithFiles ps = withXFTPServer $ do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" alice <## "chat started" alice ##> "/_files_folder ./tests/tmp/alice_files" @@ -1490,13 +1456,13 @@ testMaintenanceModeWithFiles tmp = withXFTPServer $ do alice <## "ok" B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src -- works after full restart - withTestChat tmp "alice" $ \alice -> testChatWorking alice bob + withTestChat ps "alice" $ \alice -> testChatWorking alice bob #if !defined(dbPostgres) -testDatabaseEncryption :: HasCallStack => FilePath -> IO () -testDatabaseEncryption tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testDatabaseEncryption :: HasCallStack => TestParams -> IO () +testDatabaseEncryption ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" alice <## "chat started" connectUsers alice bob @@ -1514,7 +1480,7 @@ testDatabaseEncryption tmp = do alice <## "ok" alice ##> "/_start" alice <## "error: chat store changed, please restart chat" - withTestChatOpts tmp (getTestOpts True "mykey") "alice" $ \alice -> do + withTestChatOpts ps (getTestOpts True "mykey") "alice" $ \alice -> do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob @@ -1530,7 +1496,7 @@ testDatabaseEncryption tmp = do alice <## "ok" alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}" alice <## "ok" - withTestChatOpts tmp (getTestOpts True "anotherkey") "alice" $ \alice -> do + withTestChatOpts ps (getTestOpts True "anotherkey") "alice" $ \alice -> do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob @@ -1538,15 +1504,15 @@ testDatabaseEncryption tmp = do alice <## "chat stopped" alice ##> "/db decrypt anotherkey" alice <## "ok" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do testChatWorking alice bob #endif -testSubscribeAppNSE :: HasCallStack => FilePath -> IO () -testSubscribeAppNSE tmp = - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withTestChatOpts tmp testOpts {maintenance = True} "alice" $ \nseAlice -> do +testSubscribeAppNSE :: HasCallStack => TestParams -> IO () +testSubscribeAppNSE ps = + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withTestChatOpts ps testOpts {maintenance = True} "alice" $ \nseAlice -> do alice ##> "/_app suspend 1" alice <## "ok" alice <## "chat suspended" @@ -1572,7 +1538,7 @@ testSubscribeAppNSE tmp = threadDelay 100000 alice <##> bob -testMuteContact :: HasCallStack => FilePath -> IO () +testMuteContact :: HasCallStack => TestParams -> IO () testMuteContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1592,7 +1558,7 @@ testMuteContact = alice #> "@bob hi again" bob <# "alice> hi again" -testMuteGroup :: HasCallStack => FilePath -> IO () +testMuteGroup :: HasCallStack => TestParams -> IO () testMuteGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1617,7 +1583,7 @@ testMuteGroup = cath <## " hello too!" concurrentlyN_ [ do - bob <# "#team cath> > bob hello " + bob <# "#team cath!> > bob hello " bob <## " hello too! ", do alice <# "#team cath> > bob hello" @@ -1630,7 +1596,7 @@ testMuteGroup = alice <## " hey bob!" concurrentlyN_ [ do - bob <# "#team alice> > bob hello" + bob <# "#team alice!> > bob hello" bob <## " hey bob!", do cath <# "#team alice> > bob hello" @@ -1644,7 +1610,7 @@ testMuteGroup = bob <# "#team alice> > cath hello too! " bob <## " hey cath! ", do - cath <# "#team alice> > cath hello too!" + cath <# "#team alice!> > cath hello too!" cath <## " hey cath!" ] bob ##> "/gs" @@ -1706,7 +1672,7 @@ testMuteGroup = bob ##> "/gs" bob <## "#team (3 members)" -testCreateSecondUser :: HasCallStack => FilePath -> IO () +testCreateSecondUser :: HasCallStack => TestParams -> IO () testCreateSecondUser = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1757,10 +1723,10 @@ testCreateSecondUser = alice ##> "/_user 2" showActiveUser alice "alisa" -testUsersSubscribeAfterRestart :: HasCallStack => FilePath -> IO () -testUsersSubscribeAfterRestart tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUsersSubscribeAfterRestart :: HasCallStack => TestParams -> IO () +testUsersSubscribeAfterRestart ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers alice bob alice <##> bob @@ -1769,7 +1735,7 @@ testUsersSubscribeAfterRestart tmp = do connectUsers alice bob alice <##> bob - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do -- second user is active alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -1781,7 +1747,7 @@ testUsersSubscribeAfterRestart tmp = do bob #> "@alice hey alice" (alice, "alice") $<# "bob> hey alice" -testMultipleUserAddresses :: HasCallStack => FilePath -> IO () +testMultipleUserAddresses :: HasCallStack => TestParams -> IO () testMultipleUserAddresses = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1853,7 +1819,7 @@ testMultipleUserAddresses = showActiveUser alice "alice (Alice)" alice @@@ [("@bob", "hey alice")] -testCreateUserSameServers :: HasCallStack => FilePath -> IO () +testCreateUserSameServers :: HasCallStack => TestParams -> IO () testCreateUserSameServers = testChat2 aliceProfile bobProfile $ \alice _ -> do @@ -1878,7 +1844,7 @@ testCreateUserSameServers = alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" -testDeleteUser :: HasCallStack => FilePath -> IO () +testDeleteUser :: HasCallStack => TestParams -> IO () testDeleteUser = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -1965,10 +1931,10 @@ testDeleteUser = alice ##> "/users" alice <## "no users" -testUsersDifferentCIExpirationTTL :: HasCallStack => FilePath -> IO () -testUsersDifferentCIExpirationTTL tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () +testUsersDifferentCIExpirationTTL ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- first user messages connectUsers alice bob @@ -2040,10 +2006,10 @@ testUsersDifferentCIExpirationTTL tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testUsersRestartCIExpiration :: HasCallStack => FilePath -> IO () -testUsersRestartCIExpiration tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testUsersRestartCIExpiration :: HasCallStack => TestParams -> IO () +testUsersRestartCIExpiration ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- set ttl for first user alice #$> ("/_ttl 1 2", id, "ok") connectUsers alice bob @@ -2076,7 +2042,7 @@ testUsersRestartCIExpiration tmp = do alice ##> "/user alice" showActiveUser alice "alice (Alice)" - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alisa] 1 contacts connected (use /cs for the list)" @@ -2123,10 +2089,10 @@ testUsersRestartCIExpiration tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testEnableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () -testEnableCIExpirationOnlyForOneUser tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testEnableCIExpirationOnlyForOneUser :: HasCallStack => TestParams -> IO () +testEnableCIExpirationOnlyForOneUser ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- first user messages connectUsers alice bob @@ -2175,7 +2141,7 @@ testEnableCIExpirationOnlyForOneUser tmp = do showActiveUser alice "alisa" alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2194,10 +2160,10 @@ testEnableCIExpirationOnlyForOneUser tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testDisableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () -testDisableCIExpirationOnlyForOneUser tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testDisableCIExpirationOnlyForOneUser :: HasCallStack => TestParams -> IO () +testDisableCIExpirationOnlyForOneUser ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- set ttl for first user alice #$> ("/_ttl 1 1", id, "ok") connectUsers alice bob @@ -2231,7 +2197,7 @@ testDisableCIExpirationOnlyForOneUser tmp = do -- second user messages are deleted alice #$> ("/_get chat @6 count=100", chat, []) - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2252,10 +2218,10 @@ testDisableCIExpirationOnlyForOneUser tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testUsersTimedMessages :: HasCallStack => FilePath -> IO () -testUsersTimedMessages tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUsersTimedMessages :: HasCallStack => TestParams -> IO () +testUsersTimedMessages ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers alice bob configureTimedMessages alice bob "2" "2" @@ -2338,7 +2304,7 @@ testUsersTimedMessages tmp = do bob #> "@alisa alisa 4" alice <# "bob> alisa 4" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2387,7 +2353,7 @@ testUsersTimedMessages tmp = do bob <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))") alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") -- to remove feature items -testUserPrivacy :: HasCallStack => FilePath -> IO () +testUserPrivacy :: HasCallStack => TestParams -> IO () testUserPrivacy = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2528,7 +2494,7 @@ testUserPrivacy = "bob> this won't show" ] -testSetChatItemTTL :: HasCallStack => FilePath -> IO () +testSetChatItemTTL :: HasCallStack => TestParams -> IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2562,9 +2528,9 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") -testSetDirectChatTTL :: HasCallStack => FilePath -> IO () +testSetDirectChatTTL :: HasCallStack => TestParams -> IO () testSetDirectChatTTL = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers alice cath @@ -2638,9 +2604,9 @@ testSetDirectChatTTL = alice #$> ("/ttl @cath year", id, "ok") alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one year") -testAppSettings :: HasCallStack => FilePath -> IO () -testAppSettings tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testAppSettings :: HasCallStack => TestParams -> IO () +testAppSettings ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]} -- app-provided defaults @@ -2656,7 +2622,7 @@ testAppSettings tmp = alice ##> "/_get app settings" alice <## ("app settings: " <> settingsApp) -testSwitchContact :: HasCallStack => FilePath -> IO () +testSwitchContact :: HasCallStack => TestParams -> IO () testSwitchContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2671,10 +2637,10 @@ testSwitchContact = bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob -testAbortSwitchContact :: HasCallStack => FilePath -> IO () -testAbortSwitchContact tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testAbortSwitchContact :: HasCallStack => TestParams -> IO () +testAbortSwitchContact ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #$> ("/switch bob", id, "switch started") alice <## "bob: you started changing address" @@ -2686,7 +2652,7 @@ testAbortSwitchContact tmp = do -- repeat switch stop is prohibited alice ##> "/abort switch bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected tmp "bob" $ \bob -> do + withTestChatContactConnected ps "bob" $ \bob -> do bob <## "alice started changing address for you" -- alice changes address again alice #$> ("/switch bob", id, "switch started") @@ -2699,7 +2665,7 @@ testAbortSwitchContact tmp = do bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob -testSwitchGroupMember :: HasCallStack => FilePath -> IO () +testSwitchGroupMember :: HasCallStack => TestParams -> IO () testSwitchGroupMember = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2717,10 +2683,10 @@ testSwitchGroupMember = bob #> "#team hi" alice <# "#team bob> hi" -testAbortSwitchGroupMember :: HasCallStack => FilePath -> IO () -testAbortSwitchGroupMember tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testAbortSwitchGroupMember :: HasCallStack => TestParams -> IO () +testAbortSwitchGroupMember ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #$> ("/switch #team bob", id, "switch started") alice <## "#team: you started changing address for bob" @@ -2732,7 +2698,7 @@ testAbortSwitchGroupMember tmp = do -- repeat switch stop is prohibited alice ##> "/abort switch #team bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected tmp "bob" $ \bob -> do + withTestChatContactConnected ps "bob" $ \bob -> do bob <## "#team: connected to server(s)" bob <## "#team: alice started changing address for you" -- alice changes address again @@ -2749,7 +2715,7 @@ testAbortSwitchGroupMember tmp = do bob #> "#team hi" alice <# "#team bob> hi" -testMarkContactVerified :: HasCallStack => FilePath -> IO () +testMarkContactVerified :: HasCallStack => TestParams -> IO () testMarkContactVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -2785,7 +2751,7 @@ testMarkContactVerified = | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" -testMarkGroupMemberVerified :: HasCallStack => FilePath -> IO () +testMarkGroupMemberVerified :: HasCallStack => TestParams -> IO () testMarkGroupMemberVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob @@ -2821,17 +2787,17 @@ testMarkGroupMemberVerified = | otherwise = "connection not verified, use /code command to see security code" #if !defined(dbPostgres) -testMsgDecryptError :: HasCallStack => FilePath -> IO () -testMsgDecryptError tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testMsgDecryptError :: HasCallStack => TestParams -> IO () +testMsgDecryptError ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" bob #> "@alice hey" alice <# "bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob hello again" bob <# "alice> skipped message ID 9..11" @@ -2839,10 +2805,10 @@ testMsgDecryptError tmp = bob #> "@alice received!" alice <# "bob> received!" -setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> IO () -setupDesynchronizedRatchet tmp alice = do +setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () +setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob 1" bob <# "alice> 1" @@ -2853,7 +2819,7 @@ setupDesynchronizedRatchet tmp alice = do bob #> "@alice 4" alice <# "bob> 4" threadDelay 500000 - withTestChat tmp "bob_old" $ \bob -> do + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "error: command is prohibited, synchronizeRatchet: not allowed" @@ -2869,21 +2835,22 @@ setupDesynchronizedRatchet tmp alice = do bob <## "error: command is prohibited, sendMessagesB: send prohibited" (alice (from <> chatSuffix)) (tmp (to <> chatSuffix)) copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) -testSyncRatchet :: HasCallStack => FilePath -> IO () -testSyncRatchet tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSyncRatchet :: HasCallStack => TestParams -> IO () +testSyncRatchet ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" bob #> "@alice hey" alice <# "bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "connection synchronization started" @@ -2901,10 +2868,10 @@ testSyncRatchet tmp = bob #> "@alice received!" alice <# "bob> received!" -testSyncRatchetCodeReset :: HasCallStack => FilePath -> IO () -testSyncRatchetCodeReset tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSyncRatchetCodeReset :: HasCallStack => TestParams -> IO () +testSyncRatchetCodeReset ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" @@ -2921,8 +2888,8 @@ testSyncRatchetCodeReset tmp = -- connection verified bob ##> "/i alice" aliceInfo bob True - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "connection synchronization started" @@ -2960,7 +2927,7 @@ testSyncRatchetCodeReset tmp = | otherwise = "connection not verified, use /code command to see security code" #endif -testSetMessageReactions :: HasCallStack => FilePath -> IO () +testSetMessageReactions :: HasCallStack => TestParams -> IO () testSetMessageReactions = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3008,10 +2975,10 @@ testSetMessageReactions = bob <# "alice> hi" bob <## " 👍 1" -testSendDeliveryReceipts :: HasCallStack => FilePath -> IO () -testSendDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testSendDeliveryReceipts :: HasCallStack => TestParams -> IO () +testSendDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" @@ -3024,11 +2991,11 @@ testSendDeliveryReceipts tmp = where cfg = testCfg {showReceipts = True} -testConfigureDeliveryReceipts :: HasCallStack => FilePath -> IO () -testConfigureDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testConfigureDeliveryReceipts :: HasCallStack => TestParams -> IO () +testConfigureDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice bob connectUsers alice cath @@ -3102,10 +3069,10 @@ testConfigureDeliveryReceipts tmp = cc2 <# (name1 <> "> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> FilePath -> IO () -testConnInvChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do +testConnInvChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> TestParams -> IO () +testConnInvChatVRange ct1VRange ct2VRange ps = + withNewTestChatCfg ps testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -3114,10 +3081,10 @@ testConnInvChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () -testConnReqChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do +testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> TestParams -> IO () +testConnReqChatVRange ct1VRange ct2VRange ps = + withNewTestChatCfg ps testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do alice ##> "/ad" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) @@ -3134,10 +3101,10 @@ testConnReqChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testUpdatePeerChatVRange :: HasCallStack => FilePath -> IO () -testUpdatePeerChatVRange tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg11 "bob" bobProfile $ \bob -> do +testUpdatePeerChatVRange :: HasCallStack => TestParams -> IO () +testUpdatePeerChatVRange ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg11 "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -3146,7 +3113,7 @@ testUpdatePeerChatVRange tmp = bob ##> "/i alice" contactInfoChatVRange bob supportedChatVRange - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob #> "@alice hello 1" @@ -3158,7 +3125,7 @@ testUpdatePeerChatVRange tmp = bob ##> "/i alice" contactInfoChatVRange bob supportedChatVRange - withTestChatCfg tmp cfg11 "bob" $ \bob -> do + withTestChatCfg ps cfg11 "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob #> "@alice hello 2" @@ -3172,15 +3139,15 @@ testUpdatePeerChatVRange tmp = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig -testGetNetworkStatuses :: HasCallStack => FilePath -> IO () -testGetNetworkStatuses tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGetNetworkStatuses :: HasCallStack => TestParams -> IO () +testGetNetworkStatuses ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/_network_statuses" alice <## "1 connections connected" - withTestChatCfg tmp cfg "alice" $ \alice -> - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "alice" $ \alice -> + withTestChatCfg ps cfg "bob" $ \bob -> do alice <## "1 connections connected" bob <## "1 connections connected" where diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 98b995e4ec..c573a1957a 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -6,6 +6,7 @@ module ChatTests.Files where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -24,7 +25,7 @@ import Simplex.Messaging.Encoding.String import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import Test.Hspec hiding (it) -chatFileTests :: SpecWith FilePath +chatFileTests :: SpecWith TestParams chatFileTests = do describe "messages with files" $ do it "send and receive message with file" runTestMessageWithFile @@ -63,7 +64,7 @@ chatFileTests = do xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests it "removes received temporary files" testXFTPStandaloneCancelRcv -runTestMessageWithFile :: HasCallStack => FilePath -> IO () +runTestMessageWithFile :: HasCallStack => TestParams -> IO () runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -89,7 +90,7 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg")]) bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")]) -testSendImage :: HasCallStack => FilePath -> IO () +testSendImage :: HasCallStack => TestParams -> IO () testSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -120,7 +121,7 @@ testSendImage = fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True -testSenderMarkItemDeleted :: HasCallStack => FilePath -> IO () +testSenderMarkItemDeleted :: HasCallStack => TestParams -> IO () testSenderMarkItemDeleted = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -143,7 +144,7 @@ testSenderMarkItemDeleted = bob ##> "/fs 1" bob <## "receiving file 1 (test_1MB.pdf) cancelled" -testFilesFoldersSendImage :: HasCallStack => FilePath -> IO () +testFilesFoldersSendImage :: HasCallStack => TestParams -> IO () testFilesFoldersSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -175,7 +176,7 @@ testFilesFoldersSendImage = bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" -testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () +testFilesFoldersImageSndDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageSndDelete = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -208,7 +209,7 @@ testFilesFoldersImageSndDelete = bob ##> "/d alice" bob <## "alice: contact is deleted" -testFilesFoldersImageRcvDelete :: HasCallStack => FilePath -> IO () +testFilesFoldersImageRcvDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageRcvDelete = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -235,7 +236,7 @@ testFilesFoldersImageRcvDelete = bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" -testSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () +testSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testSendImageWithTextAndQuote = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -310,7 +311,7 @@ testSendImageWithTextAndQuote = B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src -testGroupSendImage :: HasCallStack => FilePath -> IO () +testGroupSendImage :: HasCallStack => TestParams -> IO () testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -352,7 +353,7 @@ testGroupSendImage = bob #$> ("/_get chat #1 count=1", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) cath #$> ("/_get chat #1 count=1", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg")]) -testGroupSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () +testGroupSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testGroupSendImageWithTextAndQuote = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -371,9 +372,9 @@ testGroupSendImageWithTextAndQuote = alice <## "use /fc 1 to cancel sending" concurrentlyN_ [ do - bob <# "#team alice> > bob hi team" + bob <# "#team alice!> > bob hi team" bob <## " hey bob" - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <# "#team alice!> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it", do cath <# "#team alice> > bob hi team" @@ -409,7 +410,7 @@ testGroupSendImageWithTextAndQuote = cath #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) cath @@@ [("#team", "hey bob"), ("@alice", "received invitation to join group team as admin")] -testSendMultiFilesDirect :: HasCallStack => FilePath -> IO () +testSendMultiFilesDirect :: HasCallStack => TestParams -> IO () testSendMultiFilesDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -473,7 +474,7 @@ testSendMultiFilesDirect = alice #$> ("/_get chat @2 count=3", chatF, [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")]) bob #$> ("/_get chat @2 count=3", chatF, [((0, "message without file"), Nothing), ((0, "sending file 1"), Just "test.jpg"), ((0, "sending file 2"), Just "test.pdf")]) -testSendMultiFilesGroup :: HasCallStack => FilePath -> IO () +testSendMultiFilesGroup :: HasCallStack => TestParams -> IO () testSendMultiFilesGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -582,7 +583,7 @@ testXFTPRoundFDCount = do roundedFDCount 128 `shouldBe` 128 roundedFDCount 500 `shouldBe` 512 -testXFTPFileTransfer :: HasCallStack => FilePath -> IO () +testXFTPFileTransfer :: HasCallStack => TestParams -> IO () testXFTPFileTransfer = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -611,7 +612,7 @@ testXFTPFileTransfer = dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testXFTPFileTransferEncrypted :: HasCallStack => TestParams -> IO () testXFTPFileTransferEncrypted = testChat2 aliceProfile bobProfile $ \alice bob -> do src <- B.readFile "./tests/fixtures/test.pdf" @@ -638,7 +639,7 @@ testXFTPFileTransferEncrypted = LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src -testXFTPAcceptAfterUpload :: HasCallStack => FilePath -> IO () +testXFTPAcceptAfterUpload :: HasCallStack => TestParams -> IO () testXFTPAcceptAfterUpload = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -663,7 +664,7 @@ testXFTPAcceptAfterUpload = dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPGroupFileTransfer :: HasCallStack => FilePath -> IO () +testXFTPGroupFileTransfer :: HasCallStack => TestParams -> IO () testXFTPGroupFileTransfer = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -701,7 +702,7 @@ testXFTPGroupFileTransfer = dest1 `shouldBe` src dest2 `shouldBe` src -testXFTPDeleteUploadedFile :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFile :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFile = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -722,7 +723,7 @@ testXFTPDeleteUploadedFile = bob ##> "/fr 1 ./tests/tmp" bob <## "file cancelled: test.pdf" -testXFTPDeleteUploadedFileGroup :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFileGroup :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFileGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -774,7 +775,7 @@ testXFTPDeleteUploadedFileGroup = cath ##> "/fr 1 ./tests/tmp" cath <## "file cancelled: test.pdf" -testXFTPWithRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPWithRelativePaths :: HasCallStack => TestParams -> IO () testXFTPWithRelativePaths = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -802,11 +803,11 @@ testXFTPWithRelativePaths = dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src -testXFTPContinueRcv :: HasCallStack => FilePath -> IO () -testXFTPContinueRcv tmp = do +testXFTPContinueRcv :: HasCallStack => TestParams -> IO () +testXFTPContinueRcv ps = do withXFTPServer $ do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -816,7 +817,7 @@ testXFTPContinueRcv tmp = do alice <## "completed uploading file 1 (test.pdf) for bob" -- server is down - file is not received - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -831,14 +832,14 @@ testXFTPContinueRcv tmp = do withXFTPServer $ do -- server is up - file reception is continued - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPMarkToReceive :: HasCallStack => FilePath -> IO () +testXFTPMarkToReceive :: HasCallStack => TestParams -> IO () testXFTPMarkToReceive = do testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -875,11 +876,11 @@ testXFTPMarkToReceive = do dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src -testXFTPRcvError :: HasCallStack => FilePath -> IO () -testXFTPRcvError tmp = do +testXFTPRcvError :: HasCallStack => TestParams -> IO () +testXFTPRcvError ps = do withXFTPServer $ do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -890,7 +891,7 @@ testXFTPRcvError tmp = do -- server is up w/t store log - file reception should fail withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -903,7 +904,7 @@ testXFTPRcvError tmp = do bob ##> "/fs 1" bob <## "receiving file 1 (test.pdf) error: FileErrAuth" -testXFTPCancelRcvRepeat :: HasCallStack => FilePath -> IO () +testXFTPCancelRcvRepeat :: HasCallStack => TestParams -> IO () testXFTPCancelRcvRepeat = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -952,7 +953,7 @@ testXFTPCancelRcvRepeat = where cfg = testCfg {xftpDescrPartSize = 200} -testAutoAcceptFile :: HasCallStack => FilePath -> IO () +testAutoAcceptFile :: HasCallStack => TestParams -> IO () testAutoAcceptFile = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -977,7 +978,7 @@ testAutoAcceptFile = where opts = (testOpts :: ChatOpts) {autoAcceptFileSize = 200000} -testProhibitFiles :: HasCallStack => FilePath -> IO () +testProhibitFiles :: HasCallStack => TestParams -> IO () testProhibitFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath @@ -999,7 +1000,7 @@ testProhibitFiles = (bob FilePath -> IO () +testXFTPStandaloneSmall :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1024,7 +1025,7 @@ testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst srcBody <- B.readFile "./tests/fixtures/logo.jpg" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneSmallInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneSmallInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1054,7 +1055,7 @@ testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src srcBody <- B.readFile "./tests/fixtures/logo.jpg" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneLarge :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLarge :: HasCallStack => TestParams -> IO () testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1081,7 +1082,7 @@ testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst srcBody <- B.readFile "./tests/tmp/testfile.in" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneLargeInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLargeInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1114,7 +1115,7 @@ testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src srcBody <- B.readFile "./tests/tmp/testfile.in" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneCancelSnd :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelSnd :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1144,7 +1145,7 @@ testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst <## "error receiving file 1 (should.not.extist)" dst <## "INTERNAL {internalErr = \"XFTP {xftpErr = AUTH}\"}" -testXFTPStandaloneRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPStandaloneRelativePaths :: HasCallStack => TestParams -> IO () testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1175,7 +1176,7 @@ testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \ srcBody <- B.readFile "./tests/tmp/src_files/testfile.in" B.readFile "./tests/tmp/dst_files/testfile.out" `shouldReturn` srcBody -testXFTPStandaloneCancelRcv :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelRcv :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 6a1edfe3d4..483c2269b1 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -4,6 +4,7 @@ module ChatTests.Forward where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import qualified Data.ByteString.Char8 as B @@ -14,7 +15,7 @@ import Simplex.Chat.Types (ImageData (..)) import System.Directory (copyFile, doesFileExist, removeFile) import Test.Hspec hiding (it) -chatForwardTests :: SpecWith FilePath +chatForwardTests :: SpecWith TestParams chatForwardTests = do describe "forward messages" $ do it "from contact to contact" testForwardContactToContact @@ -42,7 +43,7 @@ chatForwardTests = do it "from group to group" testForwardGroupToGroupMulti it "with relative paths: multiple files from contact to contact" testMultiForwardFiles -testForwardContactToContact :: HasCallStack => FilePath -> IO () +testForwardContactToContact :: HasCallStack => TestParams -> IO () testForwardContactToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -88,7 +89,7 @@ testForwardContactToContact = alice .<## ": hey" alice <##. "forwarded from: @bob, chat item id:" -testForwardContactToGroup :: HasCallStack => FilePath -> IO () +testForwardContactToGroup :: HasCallStack => TestParams -> IO () testForwardContactToGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -112,7 +113,7 @@ testForwardContactToGroup = cath <# "#team alice> -> forwarded" cath <## " hey" -testForwardContactToNotes :: HasCallStack => FilePath -> IO () +testForwardContactToNotes :: HasCallStack => TestParams -> IO () testForwardContactToNotes = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -132,7 +133,7 @@ testForwardContactToNotes = alice <# "* <- @bob" alice <## " hey" -testForwardGroupToContact :: HasCallStack => FilePath -> IO () +testForwardGroupToContact :: HasCallStack => TestParams -> IO () testForwardGroupToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -156,7 +157,7 @@ testForwardGroupToContact = cath <# "alice> -> forwarded" cath <## " hey" -testForwardGroupToGroup :: HasCallStack => FilePath -> IO () +testForwardGroupToGroup :: HasCallStack => TestParams -> IO () testForwardGroupToGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -197,7 +198,7 @@ testForwardGroupToGroup = cath <# "#club alice> -> forwarded" cath <## " hey" -testForwardGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardGroupToNotes :: HasCallStack => TestParams -> IO () testForwardGroupToNotes = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -217,7 +218,7 @@ testForwardGroupToNotes = alice <# "* <- #team" alice <## " hey" -testForwardNotesToContact :: HasCallStack => FilePath -> IO () +testForwardNotesToContact :: HasCallStack => TestParams -> IO () testForwardNotesToContact = testChat2 aliceProfile cathProfile $ \alice cath -> do @@ -230,7 +231,7 @@ testForwardNotesToContact = alice <# "@cath hi" cath <# "alice> hi" -testForwardNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardNotesToGroup :: HasCallStack => TestParams -> IO () testForwardNotesToGroup = testChat2 aliceProfile cathProfile $ \alice cath -> do @@ -243,9 +244,9 @@ testForwardNotesToGroup = alice <# "#team hi" cath <# "#team alice> hi" -testForwardNotesToNotes :: HasCallStack => FilePath -> IO () -testForwardNotesToNotes tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testForwardNotesToNotes :: HasCallStack => TestParams -> IO () +testForwardNotesToNotes ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "hi" @@ -257,7 +258,7 @@ testForwardNotesToNotes tmp = alice <# "* hi" alice <# "* hi" -testForwardPreserveInfo :: HasCallStack => FilePath -> IO () +testForwardPreserveInfo :: HasCallStack => TestParams -> IO () testForwardPreserveInfo = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -285,7 +286,7 @@ testForwardPreserveInfo = dan <# "#team alice> -> forwarded" dan <## " hey" -testForwardRcvMsgNewInfo :: HasCallStack => FilePath -> IO () +testForwardRcvMsgNewInfo :: HasCallStack => TestParams -> IO () testForwardRcvMsgNewInfo = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -313,7 +314,7 @@ testForwardRcvMsgNewInfo = cath <# "alice> -> forwarded" cath <## " hey" -testForwardQuotedMsg :: HasCallStack => FilePath -> IO () +testForwardQuotedMsg :: HasCallStack => TestParams -> IO () testForwardQuotedMsg = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -343,7 +344,7 @@ testForwardQuotedMsg = cath <# "alice> -> forwarded" cath <## " hey" -testForwardEditProhibited :: HasCallStack => FilePath -> IO () +testForwardEditProhibited :: HasCallStack => TestParams -> IO () testForwardEditProhibited = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -363,7 +364,7 @@ testForwardEditProhibited = alice ##> ("/_update item @3 " <> msgId <> " text hey edited") alice <## "cannot update this item" -testForwardDeleteForOther :: HasCallStack => FilePath -> IO () +testForwardDeleteForOther :: HasCallStack => TestParams -> IO () testForwardDeleteForOther = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -384,7 +385,7 @@ testForwardDeleteForOther = alice <## "message marked deleted" cath <# "alice> [marked deleted] hey" -testForwardFileNoFilesFolder :: HasCallStack => FilePath -> IO () +testForwardFileNoFilesFolder :: HasCallStack => TestParams -> IO () testForwardFileNoFilesFolder = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -438,7 +439,7 @@ testForwardFileNoFilesFolder = dest2 <- B.readFile "./tests/tmp/test_1.pdf" dest2 `shouldBe` src -testForwardFileContactToContact :: HasCallStack => FilePath -> IO () +testForwardFileContactToContact :: HasCallStack => TestParams -> IO () testForwardFileContactToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -504,7 +505,7 @@ testForwardFileContactToContact = fwdFileExists <- doesFileExist "./tests/tmp/bob_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardFileGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardFileGroupToNotes :: HasCallStack => TestParams -> IO () testForwardFileGroupToNotes = testChat2 aliceProfile cathProfile $ \alice cath -> withXFTPServer $ do @@ -552,7 +553,7 @@ testForwardFileGroupToNotes = fwdFileExists <- doesFileExist "./tests/tmp/cath_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardFileNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardFileNotesToGroup :: HasCallStack => TestParams -> IO () testForwardFileNotesToGroup = testChat2 aliceProfile cathProfile $ \alice cath -> withXFTPServer $ do @@ -599,7 +600,7 @@ testForwardFileNotesToGroup = fwdFileExists <- doesFileExist "./tests/tmp/alice_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardContactToContactMulti :: HasCallStack => FilePath -> IO () +testForwardContactToContactMulti :: HasCallStack => TestParams -> IO () testForwardContactToContactMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -629,7 +630,7 @@ testForwardContactToContactMulti = cath <# "alice> -> forwarded" cath <## " hey" -testForwardGroupToGroupMulti :: HasCallStack => FilePath -> IO () +testForwardGroupToGroupMulti :: HasCallStack => TestParams -> IO () testForwardGroupToGroupMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -673,7 +674,7 @@ testForwardGroupToGroupMulti = cath <# "#club alice> -> forwarded" cath <## " hey" -testMultiForwardFiles :: HasCallStack => FilePath -> IO () +testMultiForwardFiles :: HasCallStack => TestParams -> IO () testMultiForwardFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index b9fee913d8..1cf6d0d0d6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -10,42 +10,47 @@ module ChatTests.Groups where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) +import Data.Bifunctor (second) import qualified Data.ByteString.Char8 as B import Data.List (intercalate, isInfixOf) +import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..)) -import Simplex.Chat.Messages (ChatItemId) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) +import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) +import Simplex.Chat.Markdown (parseMaybeMarkdownList) +import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Options.DB -import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Types (VersionRangeChat) -import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) +import Simplex.Chat.Types +import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport +import Simplex.Messaging.Version import Test.Hspec hiding (it) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Options.DB import System.Directory (copyFile) import System.FilePath (()) #endif -chatGroupTests :: SpecWith FilePath +chatGroupTests :: SpecWith TestParams chatGroupTests = do describe "chat groups" $ do describe "add contacts, create group and send/receive messages" testGroupMatrix + it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "mark multiple messages as read" testMarkReadGroup it "initial chat pagination" testChatPaginationInitial - it "v1: add contacts, create group and send/receive messages" testGroup - it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "send large message" testGroupLargeMessage it "create group with incognito membership" testNewGroupIncognito it "create and join group with 4 members" testGroup2 @@ -67,7 +72,6 @@ chatGroupTests = do it "group live message" testGroupLiveMessage it "update group profile" testUpdateGroupProfile it "update member role" testUpdateMemberRole - it "unused contacts are deleted after all their groups are deleted" testGroupDeleteUnusedContacts it "group description is shown as the first message to new members" testGroupDescription it "moderate message of another group member" testGroupModerate it "moderate own message (should process as deletion)" testGroupModerateOwn @@ -75,40 +79,39 @@ chatGroupTests = do it "moderate message of another group member (full delete)" testGroupModerateFullDelete it "moderate message that arrives after the event of moderation" testGroupDelayedModeration it "moderate message that arrives after the event of moderation (full delete)" testGroupDelayedModerationFullDelete + it "remove member with messages (full deletion is enabled)" testDeleteMemberWithMessages + it "remove member with messages mark deleted" testDeleteMemberMarkMessagesDeleted describe "batch send messages" $ do it "send multiple messages api" testSendMulti it "send multiple timed messages" testSendMultiTimed it "send multiple messages (many chat batches)" testSendMultiManyBatches + xit'' "shared message body is reused" testSharedMessageBody + xit'' "shared batch body is reused" testSharedBatchBody describe "async group connections" $ do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do it "create group link, join via group link" testGroupLink + it "invitees were previously connected as contacts" testGroupLinkInviteesWereConnected + it "all members were previously connected as contacts" testGroupLinkAllMembersWereConnected it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin - it "sending message to contact created via group link marks it used" testGroupLinkContactUsed - it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership - it "unused host contact is deleted after all groups with it are deleted" testGroupLinkUnusedHostContactDeleted - it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted + it "host incognito" testGroupLinkHostIncognito + it "invitee incognito" testGroupLinkInviteeIncognito + it "incognito - join/invite" testGroupLinkIncognitoJoinInvite it "group link member role" testGroupLinkMemberRole - it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete + it "host profile received" testGroupLinkHostProfileReceived + it "existing contact merged" testGroupLinkExistingContactMerged + describe "group links - member screening" $ do + it "reject member - blocked name" testGLinkRejectBlockedName + it "accept member - only host approval" testGLinkApproveMember + it "accept member - only moderators review" testGLinkReviewMember + it "accept member - host approval, then moderators review" testGLinkApproveThenReviewMember + it "delete pending approval member" testGLinkDeletePendingApprovalMember describe "group link connection plan" $ do - it "group link ok to connect; known group" testPlanGroupLinkOkKnown - it "group is known if host contact was deleted" testPlanHostContactDeletedGroupLinkKnown + it "ok to connect; known group" testPlanGroupLinkKnown it "own group link" testPlanGroupLinkOwn - it "connecting via group link" testPlanGroupLinkConnecting + it "group link without contact - connecting" testPlanGroupLinkConnecting + it "group link without contact - connecting (slow handshake)" testPlanGroupLinkConnectingSlow it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin - describe "group links without contact" $ do - it "join via group link without creating contact" testGroupLinkNoContact - it "invitees were previously connected as contacts" testGroupLinkNoContactInviteesWereConnected - it "all members were previously connected as contacts" testGroupLinkNoContactAllMembersWereConnected - it "group link member role" testGroupLinkNoContactMemberRole - it "host incognito" testGroupLinkNoContactHostIncognito - it "invitee incognito" testGroupLinkNoContactInviteeIncognito - it "host profile received" testGroupLinkNoContactHostProfileReceived - it "existing contact merged" testGroupLinkNoContactExistingContactMerged - describe "group links without contact connection plan" $ do - it "group link without contact - known group" testPlanGroupLinkNoContactKnown - it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting - it "group link without contact - connecting (slow handshake)" testPlanGroupLinkNoContactConnectingSlow #if !defined(dbPostgres) -- TODO [postgres] restore from outdated db backup (same as in agent) describe "group message errors" $ do @@ -122,11 +125,7 @@ chatGroupTests = do it "should send delivery receipts in group" testSendGroupDeliveryReceipts it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts describe "direct connections in group are not established based on chat protocol version" $ do - describe "3 members group" $ do - testNoDirect _0 _0 True - testNoDirect _0 _1 True - testNoDirect _1 _0 False - testNoDirect _1 _1 False + it "direct contacts are not created" testNoGroupDirectConns it "members have different local display names in different groups" testNoDirectDifferentLDNs describe "merge members and contacts" $ do it "new member should merge with existing contact" testMergeMemberExistingContact @@ -144,6 +143,7 @@ chatGroupTests = do it "re-create member contact after deletion, many groups" testRecreateMemberContactManyGroups describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate it "forward message edit (x.msg.update)" testGroupMsgForwardEdit it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction @@ -180,42 +180,36 @@ chatGroupTests = do it "messages are fully deleted" testBlockForAllFullDelete it "another admin can unblock" testBlockForAllAnotherAdminUnblocks it "member was blocked before joining group" testBlockForAllBeforeJoining - it "can't repeat block, unblock" testBlockForAllCantRepeat + it "repeat block, unblock" testBlockForAllRepeat + it "block multiple members" testBlockForAllMultipleMembers describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive describe "group member reports" $ do it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports - where - _0 = supportedChatVRange -- don't create direct connections - _1 = groupCreateDirectVRange - -- having host configured with older version doesn't have effect in tests - -- because host uses current code and sends version in MemberInfo - testNoDirect vrMem2 vrMem3 noConns = - it - ( "host " - <> vRangeStr supportedChatVRange - <> (", 2nd mem " <> vRangeStr vrMem2) - <> (", 3rd mem " <> vRangeStr vrMem3) - <> (if noConns then " : 2 3" else " : 2 <##> 3") - ) - $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns + describe "group member mentions" $ do + it "should send and edit messages with member mentions" testMemberMention + it "should forward and quote message updating mentioned member name" testForwardQuoteMention + it "should send updated mentions in history" testGroupHistoryWithMentions + describe "uniqueMsgMentions" testUniqueMsgMentions + describe "updatedMentionNames" testUpdatedMentionNames + describe "group scoped messages" $ do + it "should send scoped messages to support (single moderator)" testScopedSupportSingleModerator + it "should send scoped messages to support (many moderators)" testScopedSupportManyModerators + it "should send messages to admins and members" testSupportCLISendCommand + it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead + it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete -testGroup :: HasCallStack => FilePath -> IO () -testGroup = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> testGroupShared alice bob cath False True - -testGroupCheckMessages :: HasCallStack => FilePath -> IO () +testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> testGroupShared alice bob cath True True + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> testGroupShared alice bob cath True -testGroupMatrix :: SpecWith FilePath +testGroupMatrix :: SpecWith TestParams testGroupMatrix = - versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False False + versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False -testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> Bool -> IO () -testGroupShared alice bob cath checkMessages directConnections = do +testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> IO () +testGroupShared alice bob cath checkMessages = do connectUsers alice bob connectUsers alice cath alice ##> "/g team" @@ -267,8 +261,6 @@ testGroupShared alice bob cath checkMessages directConnections = do (alice <# "#team cath> hey team") (bob <# "#team cath> hey team") msgItem2 <- lastItemId alice - when directConnections $ - bob <##> cath when checkMessages $ getReadChats msgItem1 msgItem2 -- list groups alice ##> "/gs" @@ -287,7 +279,7 @@ testGroupShared alice bob cath checkMessages directConnections = do -- test observer role alice ##> "/mr team bob observer" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from admin to observer", + [ alice <## "#team: you changed the role of bob to observer", bob <## "#team: alice changed your role from admin to observer", cath <## "#team: alice changed the role of bob from admin to observer" ] @@ -302,7 +294,7 @@ testGroupShared alice bob cath checkMessages directConnections = do ] alice ##> "/mr team bob admin" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from observer to admin", + [ alice <## "#team: you changed the role of bob to admin", bob <## "#team: alice changed your role from observer to admin", cath <## "#team: alice changed the role of bob from observer to admin" ] @@ -324,9 +316,7 @@ testGroupShared alice bob cath checkMessages directConnections = do (bob <# "#team alice> hello") (cath "#team hello" - cath <## "you are no longer a member of the group" - when directConnections $ - bob <##> cath + cath <## "bad chat command: not current member" -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" @@ -339,7 +329,7 @@ testGroupShared alice bob cath checkMessages directConnections = do alice <# "#team bob> received" when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] - bob @@@ [("@alice", "contact deleted"), ("@cath", "hey"), ("#team", "received")] + bob @@@ [("@alice", "contact deleted"), ("#team", "received")] -- test clearing chat threadDelay 1000000 alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") @@ -359,9 +349,9 @@ testGroupShared alice bob cath checkMessages directConnections = do alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, sndGroupFeatures <> [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 around=" <> msgItem1 <> " count=2", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) - bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] + bob @@@ [("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) - cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] + cath @@@ [("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] cath #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "connected"), (0, "hello"), (0, "hi there"), (1, "hey team")]) alice #$> ("/_read chat #1", id, "ok") bob #$> ("/_read chat #1", id, "ok") @@ -369,7 +359,7 @@ testGroupShared alice bob cath checkMessages directConnections = do alice #$> ("/_unread chat #1 on", id, "ok") alice #$> ("/_unread chat #1 off", id, "ok") -testMarkReadGroup :: HasCallStack => FilePath -> IO () +testMarkReadGroup :: HasCallStack => TestParams -> IO () testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob alice #> "#team 1" @@ -383,9 +373,9 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do bob ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine bob let itemIds = intercalate "," $ map show [i - 3 .. i] - bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") + bob #$> ("/_read chat items #1 " <> itemIds, id, "items read for chat") -testChatPaginationInitial :: HasCallStack => FilePath -> IO () +testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob -- Wait, otherwise ids are going to be wrong. @@ -404,7 +394,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map groupItemId [1 .. 2] - bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") + bob #$> ("/_read chat items #1 " <> itemIds, id, "items read for chat") bob #$> ("/_get chat #1 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) -- Read all items @@ -417,7 +407,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice { markRead = False } -testGroupLargeMessage :: HasCallStack => FilePath -> IO () +testGroupLargeMessage :: HasCallStack => TestParams -> IO () testGroupLargeMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -431,9 +421,9 @@ testGroupLargeMessage = bob <## "alice updated group #team:" bob <## "profile image updated" -testNewGroupIncognito :: HasCallStack => FilePath -> IO () +testNewGroupIncognito :: HasCallStack => TestParams -> IO () testNewGroupIncognito = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -452,22 +442,35 @@ testNewGroupIncognito = bob ##> ("/c " <> gLink) bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." - _ <- getTermLine alice concurrentlyN_ - [ do - alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) - alice <## "use /i bob_1 to print out this incognito profile again" - alice <## "bob_1 invited to group #team via your group link" - alice <## "#team: bob_1 joined the group", + [ alice <## "#team: bob_1 joined the group", do - bob <## (aliceIncognito <> ": contact is connected") + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] alice <##> bob - alice ?#> "@bob_1 hi, I'm incognito" - bob <# (aliceIncognito <> "> hi, I'm incognito") + alice ##> "@#team bob_1 hi, I'm incognito" + alice + <### [ "member #team bob_1 does not have direct connection, creating", + "contact for member #team bob_1 is created", + "sent invitation to connect directly to member #team bob_1", + WithTime "i @bob_1 hi, I'm incognito" + ] + bob + <### [ ConsoleString ("#team " <> aliceIncognito <> " is creating direct contact " <> aliceIncognito <> " with you"), + WithTime (aliceIncognito <> "> hi, I'm incognito") + ] + bob <## (aliceIncognito <> ": you can send messages to contact") + _ <- getTermLine alice + concurrentlyN_ + [ do + alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob_1 to print out this incognito profile again", + bob <## (aliceIncognito <> ": contact is connected") + ] + bob #> ("@" <> aliceIncognito <> " hey, I'm bob") alice ?<# "bob_1> hey, I'm bob" @@ -481,9 +484,9 @@ testNewGroupIncognito = bob ##> "/gs" bob <## "#team (2 members)" -testGroup2 :: HasCallStack => FilePath -> IO () +testGroup2 :: HasCallStack => TestParams -> IO () testGroup2 = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob connectUsers alice cath @@ -534,14 +537,14 @@ testGroup2 = dan <## "#club: you joined the group" dan <### [ "#club: member alice_1 (Alice) is connected", - "contact alice_1 is merged into alice", + "contact and member are merged: alice, #club alice_1", "use @alice to send messages", "#club: member cath (Catherine) is connected" ], do alice <## "#club: bob added dan_1 (Daniel) to the group (connecting...)" alice <## "#club: new member dan_1 is connected" - alice <## "contact dan_1 is merged into dan" + alice <## "contact and member are merged: dan, #club dan_1" alice <## "use @dan to send messages", do cath <## "#club: bob added dan (Daniel) to the group (connecting...)" @@ -571,11 +574,9 @@ testGroup2 = bob <# "#club dan> how is it going?", cath <# "#club dan> how is it going?" ] - bob <##> cath - dan <##> cath dan <##> alice -- show last messages - alice ##> "/t #club 17" + alice ##> "/t #club 19" alice -- these strings are expected in any order because of sorting by time and rounding of time for sent <##? ( map (ConsoleString . ("#club " <> )) groupFeatureStrs @@ -652,10 +653,9 @@ testGroup2 = (dan "#club how is it going?" - dan <## "you are no longer a member of the group" + dan <## "bad chat command: not current member" dan ##> "/d #club" dan <## "#club: you deleted the group" - dan <##> cath dan <##> alice -- member leaves bob ##> "/l club" @@ -675,13 +675,12 @@ testGroup2 = (alice <# "#club cath> hey") (bob "#club how is it going?" - bob <## "you are no longer a member of the group" + bob <## "bad chat command: not current member" bob ##> "/d #club" bob <## "#club: you deleted the group" - bob <##> cath bob <##> alice -testGroupDelete :: HasCallStack => FilePath -> IO () +testGroupDelete :: HasCallStack => TestParams -> IO () testGroupDelete = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -701,7 +700,7 @@ testGroupDelete = bob ##> "/d #team" bob <## "#team: you deleted the group" cath ##> "#team hi" - cath <## "you are no longer a member of the group" + cath <## "bad chat command: not current member" cath ##> "/d #team" cath <## "#team: you deleted the group" alice <##> bob @@ -717,7 +716,7 @@ testGroupDelete = where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} -testGroupSameName :: HasCallStack => FilePath -> IO () +testGroupSameName :: HasCallStack => TestParams -> IO () testGroupSameName = testChat2 aliceProfile bobProfile $ \alice _ -> do @@ -729,7 +728,7 @@ testGroupSameName = alice <## "group #team_1 is created" alice <## "to add members use /a team_1 or /create link #team_1" -testGroupDeleteWhenInvited :: HasCallStack => FilePath -> IO () +testGroupDeleteWhenInvited :: HasCallStack => TestParams -> IO () testGroupDeleteWhenInvited = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -756,7 +755,7 @@ testGroupDeleteWhenInvited = bob <## "use /j team to accept" ] -testGroupReAddInvited :: HasCallStack => FilePath -> IO () +testGroupReAddInvited :: HasCallStack => TestParams -> IO () testGroupReAddInvited = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -791,7 +790,7 @@ testGroupReAddInvited = bob <## "use /j team_1 to accept" ] -testGroupReAddInvitedChangeRole :: HasCallStack => FilePath -> IO () +testGroupReAddInvitedChangeRole :: HasCallStack => TestParams -> IO () testGroupReAddInvitedChangeRole = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -831,7 +830,7 @@ testGroupReAddInvitedChangeRole = alice ##> "/d #team" alice <## "#team: you deleted the group" -testGroupDeleteInvitedContact :: HasCallStack => FilePath -> IO () +testGroupDeleteInvitedContact :: HasCallStack => TestParams -> IO () testGroupDeleteInvitedContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -876,7 +875,7 @@ testGroupDeleteInvitedContact = (bob <## "alice (Alice): contact is connected") alice <##> bob -testDeleteGroupMemberProfileKept :: HasCallStack => FilePath -> IO () +testDeleteGroupMemberProfileKept :: HasCallStack => TestParams -> IO () testDeleteGroupMemberProfileKept = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -946,9 +945,9 @@ testDeleteGroupMemberProfileKept = bob #> "#club received" alice <# "#club bob> received" -testGroupRemoveAdd :: HasCallStack => FilePath -> IO () +testGroupRemoveAdd :: HasCallStack => TestParams -> IO () testGroupRemoveAdd = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -975,14 +974,10 @@ testGroupRemoveAdd = [ alice <## "#team: bob joined the group", do bob <## "#team_1: you joined the group" - bob <## "#team_1: member cath_1 (Catherine) is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages", + bob <## "#team_1: member cath_1 (Catherine) is connected", do cath <## "#team: alice added bob_1 (Bob) to the group (connecting...)" cath <## "#team: new member bob_1 is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages" ] alice #> "#team hi" concurrently_ @@ -991,13 +986,13 @@ testGroupRemoveAdd = bob #> "#team_1 hey" concurrently_ (alice <# "#team bob> hey") - (cath <# "#team bob> hey") + (cath <# "#team bob_1> hey") cath #> "#team hello" concurrently_ (alice <# "#team cath> hello") - (bob <# "#team_1 cath> hello") + (bob <# "#team_1 cath_1> hello") -testGroupList :: HasCallStack => FilePath -> IO () +testGroupList :: HasCallStack => TestParams -> IO () testGroupList = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1027,9 +1022,9 @@ testGroupList = bob ##> "/gs" bob <## "#team (2 members)" -testGroupMessageQuotedReply :: HasCallStack => FilePath -> IO () +testGroupMessageQuotedReply :: HasCallStack => TestParams -> IO () testGroupMessageQuotedReply = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -1043,7 +1038,7 @@ testGroupMessageQuotedReply = bob <## " hello, all good, you?" concurrently_ ( do - alice <# "#team bob> > alice hello! how are you?" + alice <# "#team bob!> > alice hello! how are you?" alice <## " hello, all good, you?" ) ( do @@ -1078,7 +1073,7 @@ testGroupMessageQuotedReply = alice <## " hi there!" ) ( do - bob <# "#team cath> > bob hello, all good, you?" + bob <# "#team cath!> > bob hello, all good, you?" bob <## " hi there!" ) cath #$> ("/_get chat #1 count=1", chat', [((1, "hi there!"), Just (0, "hello, all good, you?"))]) @@ -1089,7 +1084,7 @@ testGroupMessageQuotedReply = alice <## " go on" concurrently_ ( do - bob <# "#team alice> > bob will tell more" + bob <# "#team alice!> > bob will tell more" bob <## " go on" ) ( do @@ -1097,7 +1092,7 @@ testGroupMessageQuotedReply = cath <## " go on" ) -testGroupMessageUpdate :: HasCallStack => FilePath -> IO () +testGroupMessageUpdate :: HasCallStack => TestParams -> IO () testGroupMessageUpdate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1130,7 +1125,7 @@ testGroupMessageUpdate = bob <## " hi alice" concurrently_ ( do - alice <# "#team bob> > alice hey 👋" + alice <# "#team bob!> > alice hey 👋" alice <## " hi alice" ) ( do @@ -1157,7 +1152,7 @@ testGroupMessageUpdate = cath <## " greetings!" concurrently_ ( do - alice <# "#team cath> > alice greetings 🤝" + alice <# "#team cath!> > alice greetings 🤝" alice <## " greetings!" ) ( do @@ -1169,7 +1164,7 @@ testGroupMessageUpdate = bob #$> ("/_get chat #1 count=3", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hey 👋")), ((0, "greetings!"), Just (0, "greetings 🤝"))]) cath #$> ("/_get chat #1 count=3", chat', [((0, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (0, "hey 👋")), ((1, "greetings!"), Just (0, "greetings 🤝"))]) -testGroupMessageEditHistory :: HasCallStack => FilePath -> IO () +testGroupMessageEditHistory :: HasCallStack => TestParams -> IO () testGroupMessageEditHistory = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1243,11 +1238,12 @@ testGroupMessageEditHistory = bob <## "message history:" bob .<## ": hey there" -testGroupMessageDelete :: HasCallStack => FilePath -> IO () +testGroupMessageDelete :: HasCallStack => TestParams -> IO () testGroupMessageDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 -- alice, bob: msg id 5, cath: msg id 4 (after group invitations & group events) alice #> "#team hello!" @@ -1259,7 +1255,7 @@ testGroupMessageDelete = msgItemId1 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId1 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=1", chat, [(0, "connected")]) + alice #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "connected")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) @@ -1270,7 +1266,7 @@ testGroupMessageDelete = bob <## " hi alic" concurrently_ ( do - alice <# "#team bob> > alice hello!" + alice <# "#team bob!> > alice hello!" alice <## " hi alic" ) ( do @@ -1285,7 +1281,7 @@ testGroupMessageDelete = msgItemId2 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId2 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=1", chat', [((0, "connected"), Nothing)]) + alice #$> ("/_get chat #1 count=2", chat', [((0, "connected"), Nothing), ((0, "connected"), Nothing)]) bob #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((1, "hi alic"), Just (0, "hello!"))]) cath #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((0, "hi alic"), Just (0, "hello!"))]) @@ -1327,11 +1323,12 @@ testGroupMessageDelete = bob #$> ("/_get chat #1 count=3", chat', [((0, "hello!"), Nothing), ((1, "hi alice"), Just (0, "hello!")), ((0, "how are you? [marked deleted]"), Nothing)]) cath #$> ("/_get chat #1 count=3", chat', [((0, "hello!"), Nothing), ((0, "hi alice"), Just (0, "hello!")), ((1, "how are you? [marked deleted]"), Nothing)]) -testGroupMessageDeleteMultiple :: HasCallStack => FilePath -> IO () +testGroupMessageDeleteMultiple :: HasCallStack => TestParams -> IO () testGroupMessageDeleteMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1363,11 +1360,12 @@ testGroupMessageDeleteMultiple = bob #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) -testGroupMessageDeleteMultipleManyBatches :: HasCallStack => FilePath -> IO () +testGroupMessageDeleteMultipleManyBatches :: HasCallStack => TestParams -> IO () testGroupMessageDeleteMultipleManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob ##> "/set receipts all off" bob <## "ok" @@ -1401,7 +1399,7 @@ testGroupMessageDeleteMultipleManyBatches = (bob <# ("#team alice> [marked deleted] message " <> show i)) (cath <# ("#team alice> [marked deleted] message " <> show i)) -testGroupLiveMessage :: HasCallStack => FilePath -> IO () +testGroupLiveMessage :: HasCallStack => TestParams -> IO () testGroupLiveMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -1439,7 +1437,7 @@ testGroupLiveMessage = bob .<## ": hello 2" bob .<## ":" -testUpdateGroupProfile :: HasCallStack => FilePath -> IO () +testUpdateGroupProfile :: HasCallStack => TestParams -> IO () testUpdateGroupProfile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1466,7 +1464,7 @@ testUpdateGroupProfile = (alice <# "#my_team bob> hi") (cath <# "#my_team bob> hi") -testUpdateMemberRole :: HasCallStack => FilePath -> IO () +testUpdateMemberRole :: HasCallStack => TestParams -> IO () testUpdateMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1476,7 +1474,7 @@ testUpdateMemberRole = alice <## "to add members use /a team or /create link #team" addMember "team" alice bob GRAdmin alice ##> "/mr team bob member" - alice <## "#team: you changed the role of bob from admin to member" + alice <## "#team: you changed the role of bob to member" bob <## "#team: alice invites you to join the group as member" bob <## "use /j team to accept" bob ##> "/j team" @@ -1488,7 +1486,7 @@ testUpdateMemberRole = bob <## "#team: you have insufficient permissions for this action, the required role is admin" alice ##> "/mr team bob admin" concurrently_ - (alice <## "#team: you changed the role of bob from member to admin") + (alice <## "#team: you changed the role of bob to admin") (bob <## "#team: alice changed your role from member to admin") bob ##> "/a team cath owner" bob <## "#team: you have insufficient permissions for this action, the required role is owner" @@ -1504,107 +1502,17 @@ testUpdateMemberRole = alice <## "#team: new member cath is connected" ] alice ##> "/mr team alice admin" - concurrentlyN_ - [ alice <## "#team: you changed your role from owner to admin", - bob <## "#team: alice changed the role from owner to admin", - cath <## "#team: alice changed the role from owner to admin" - ] - alice ##> "/d #team" - alice <## "#team: you have insufficient permissions for this action, the required role is owner" + alice <## "bad chat command: can't change role for self" -testGroupDeleteUnusedContacts :: HasCallStack => FilePath -> IO () -testGroupDeleteUnusedContacts = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - -- create group 1 - createGroup3 "team" alice bob cath - -- create group 2 - alice ##> "/g club" - alice <## "group #club is created" - alice <## "to add members use /a club or /create link #club" - alice ##> "/a club bob" - concurrentlyN_ - [ alice <## "invitation to join the group #club sent to bob", - do - bob <## "#club: alice invites you to join the group as member" - bob <## "use /j club to accept" - ] - bob ##> "/j club" - concurrently_ - (alice <## "#club: bob joined the group") - (bob <## "#club: you joined the group") - alice ##> "/a club cath" - concurrentlyN_ - [ alice <## "invitation to join the group #club sent to cath", - do - cath <## "#club: alice invites you to join the group as member" - cath <## "use /j club to accept" - ] - cath ##> "/j club" - concurrentlyN_ - [ alice <## "#club: cath joined the group", - do - cath <## "#club: you joined the group" - cath <## "#club: member bob_1 (Bob) is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages", - do - bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)" - bob <## "#club: new member cath_1 is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages" - ] - -- list contacts - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - cath ##> "/contacts" - cath <## "alice (Alice)" - cath <## "bob (Bob)" - -- delete group 1, contacts and profiles are kept - deleteGroup alice bob cath "team" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath ##> "/contacts" - cath <## "alice (Alice)" - cath <## "bob (Bob)" - cath `hasContactProfiles` ["alice", "bob", "cath"] - -- delete group 2, unused contacts and profiles are deleted - deleteGroup alice bob cath "club" - threadDelay 3000000 - bob ##> "/contacts" - bob <## "alice (Alice)" - bob `hasContactProfiles` ["alice", "bob"] - cath ##> "/contacts" - cath <## "alice (Alice)" - cath `hasContactProfiles` ["alice", "cath"] - where - cfg = mkCfgCreateGroupDirect $ testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} - deleteGroup :: HasCallStack => TestCC -> TestCC -> TestCC -> String -> IO () - deleteGroup alice bob cath group = do - alice ##> ("/d #" <> group) - concurrentlyN_ - [ alice <## ("#" <> group <> ": you deleted the group"), - do - bob <## ("#" <> group <> ": alice deleted the group") - bob <## ("use /d #" <> group <> " to delete the local copy of the group"), - do - cath <## ("#" <> group <> ": alice deleted the group") - cath <## ("use /d #" <> group <> " to delete the local copy of the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - cath ##> ("/d #" <> group) - cath <## ("#" <> group <> ": you deleted the group") - -testGroupDescription :: HasCallStack => FilePath -> IO () +testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" + -- alice ##> "/set delete #team off" + -- alice <## "updated group preferences:" + -- alice <## "Full deletion: off" addMember "team" alice bob GRAdmin bob ##> "/j team" concurrentlyN_ @@ -1613,7 +1521,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile ] alice ##> "/group_profile team" alice <## "#team" - groupInfo alice + groupInfo' alice alice ##> "/group_descr team Welcome to the team!" alice <## "description changed to:" alice <## "Welcome to the team!" @@ -1624,7 +1532,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "#team" alice <## "description:" alice <## "Welcome to the team!" - groupInfo alice + groupInfo' alice connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1654,8 +1562,8 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile bobAddedDan cath ] where - groupInfo :: HasCallStack => TestCC -> IO () - groupInfo alice = do + groupInfo' :: HasCallStack => TestCC -> IO () + groupInfo' alice = do alice <## "group preferences:" alice <## "Disappearing messages: off" alice <## "Direct messages: on" @@ -1664,20 +1572,22 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "Voice messages: on" alice <## "Files and media: on" alice <## "SimpleX links: on" + alice <## "Member reports: on" alice <## "Recent history: on" bobAddedDan :: HasCallStack => TestCC -> IO () bobAddedDan cc = do cc <## "#team: bob added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testGroupModerate :: HasCallStack => FilePath -> IO () +testGroupModerate :: HasCallStack => TestParams -> IO () testGroupModerate = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -1701,11 +1611,12 @@ testGroupModerate = bob #$> ("/_get chat #1 count=1", chat, [(0, "hi [marked deleted by you]")]) cath #$> ("/_get chat #1 count=1", chat, [(1, "hi [marked deleted by bob]")]) -testGroupModerateOwn :: HasCallStack => FilePath -> IO () +testGroupModerateOwn :: HasCallStack => TestParams -> IO () testGroupModerateOwn = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob + -- disableFullDeletion2 "team" alice bob threadDelay 1000000 alice #> "#team hello" bob <# "#team alice> hello" @@ -1715,11 +1626,12 @@ testGroupModerateOwn = alice #$> ("/_get chat #1 count=1", chat, [(1, "hello [marked deleted by you]")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "hello [marked deleted by alice]")]) -testGroupModerateMultiple :: HasCallStack => FilePath -> IO () +testGroupModerateMultiple :: HasCallStack => TestParams -> IO () testGroupModerateMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1750,14 +1662,15 @@ testGroupModerateMultiple = bob #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (1, "hey [marked deleted by alice]")]) cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "hey [marked deleted by alice]")]) -testGroupModerateFullDelete :: HasCallStack => FilePath -> IO () +testGroupModerateFullDelete :: HasCallStack => TestParams -> IO () testGroupModerateFullDelete = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -1788,12 +1701,13 @@ testGroupModerateFullDelete = bob #$> ("/_get chat #1 count=1", chat, [(0, "moderated [deleted by you]")]) cath #$> ("/_get chat #1 count=1", chat, [(1, "moderated [deleted by bob]")]) -testGroupDelayedModeration :: HasCallStack => FilePath -> IO () -testGroupDelayedModeration tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGroupDelayedModeration :: HasCallStack => TestParams -> IO () +testGroupDelayedModeration ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + -- disableFullDeletion2 "team" alice bob + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1814,12 +1728,12 @@ testGroupDelayedModeration tmp = do alice ##> "\\\\ #team @cath hi" alice <## "message marked deleted by you" cath <# "#team cath> [marked deleted by alice] hi" - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - withTestChatCfg tmp cfg "cath" $ \cath -> do - cath <## "2 contacts connected (use /cs for the list)" + withTestChatCfg ps cfg "cath" $ \cath -> do + cath <## "1 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" bob @@ -1832,14 +1746,16 @@ testGroupDelayedModeration tmp = do r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "connected"), (0, "hi [marked deleted by alice]")] where - cfg = testCfgCreateGroupDirect + -- version before forwarding, so cath doesn't expect alice to forward messages (groupForwardVersion = 4) + cfg = testCfg {chatVRange = mkVersionRange (VersionChat 1) (VersionChat 3)} -testGroupDelayedModerationFullDelete :: HasCallStack => FilePath -> IO () -testGroupDelayedModerationFullDelete tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGroupDelayedModerationFullDelete :: HasCallStack => TestParams -> IO () +testGroupDelayedModerationFullDelete ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + -- disableFullDeletion2 "team" alice bob + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1868,15 +1784,15 @@ testGroupDelayedModerationFullDelete tmp = do cath <## "alice updated group #team:" cath <## "updated group preferences:" cath <## "Full deletion: on" - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" - withTestChatCfg tmp cfg "cath" $ \cath -> do - cath <## "2 contacts connected (use /cs for the list)" + withTestChatCfg ps cfg "cath" $ \cath -> do + cath <## "1 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" bob @@ -1889,9 +1805,71 @@ testGroupDelayedModerationFullDelete tmp = do r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "Full deletion: on"), (0, "connected"), (0, "moderated [deleted by alice]")] where - cfg = testCfgCreateGroupDirect + -- version before forwarding, so cath doesn't expect alice to forward messages (groupForwardVersion = 4) + cfg = testCfg {chatVRange = mkVersionRange (VersionChat 1) (VersionChat 3)} -testSendMulti :: HasCallStack => FilePath -> IO () +testDeleteMemberWithMessages :: HasCallStack => TestParams -> IO () +testDeleteMemberWithMessages = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + threadDelay 750000 + alice ##> "/set delete #team on" + alice <## "updated group preferences:" + alice <## "Full deletion: on" + threadDelay 750000 + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "updated group preferences:" + bob <## "Full deletion: on", + do + cath <## "alice updated group #team:" + cath <## "updated group preferences:" + cath <## "Full deletion: on" + ] + threadDelay 750000 + bob #> "#team hello" + concurrently_ + (alice <# "#team bob> hello") + (cath <# "#team bob> hello") + alice #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + bob #$> ("/_get chat #1 count=1", chat, [(1, "hello")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + threadDelay 1000000 + alice ##> "/rm #team bob messages=on" + alice <## "#team: you removed bob from the group with all messages" + bob <## "#team: alice removed you from the group with all messages" + bob <## "use /d #team to delete the group" + cath <## "#team: alice removed bob from the group with all messages" + alice #$> ("/_get chat #1 count=2", chat, [(0, "moderated [deleted by you]"), (1, "removed bob (Bob)")]) + bob #$> ("/_get chat #1 count=2", chat, [(1, "moderated [deleted by alice]"), (0, "removed you")]) + cath #$> ("/_get chat #1 count=2", chat, [(0, "moderated [deleted by alice]"), (0, "removed bob (Bob)")]) + +testDeleteMemberMarkMessagesDeleted :: HasCallStack => TestParams -> IO () +testDeleteMemberMarkMessagesDeleted = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + threadDelay 1000000 + bob #> "#team hello" + concurrently_ + (alice <# "#team bob> hello") + (cath <# "#team bob> hello") + alice #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + bob #$> ("/_get chat #1 count=1", chat, [(1, "hello")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + threadDelay 1000000 + alice ##> "/rm #team bob messages=on" + alice <## "#team: you removed bob from the group with all messages" + bob <## "#team: alice removed you from the group with all messages" + bob <## "use /d #team to delete the group" + cath <## "#team: alice removed bob from the group with all messages" + alice #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by you]"), (1, "removed bob (Bob)")]) + bob #$> ("/_get chat #1 count=2", chat, [(1, "hello [marked deleted by alice]"), (0, "removed you")]) + cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "removed bob (Bob)")]) + +testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1905,7 +1883,7 @@ testSendMulti = cath <# "#team alice> test 1" cath <# "#team alice> test 2" -testSendMultiTimed :: HasCallStack => FilePath -> IO () +testSendMultiTimed :: HasCallStack => TestParams -> IO () testSendMultiTimed = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1942,7 +1920,7 @@ testSendMultiTimed = "timed message deleted: test 2" ] -testSendMultiManyBatches :: HasCallStack => FilePath -> IO () +testSendMultiManyBatches :: HasCallStack => TestParams -> IO () testSendMultiManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1977,10 +1955,114 @@ testSendMultiManyBatches = DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdCath) :: IO [[Int]] cathItemsCount `shouldBe` [[300]] -testGroupAsync :: HasCallStack => FilePath -> IO () -testGroupAsync tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSharedMessageBody :: HasCallStack => TestParams -> IO () +testSharedMessageBody ps = + withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> do + withSmpServer' serverCfg' $ + withNewTestChatOpts ps opts' "bob" bobProfile $ \bob -> + withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + + alice <##. "server disconnected localhost" + alice #> "#team hello" + bodiesCount1 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount1 `shouldBe` [[1]] + + withSmpServer' serverCfg' $ + withTestChatOpts ps opts' "bob" $ \bob -> + withTestChatOpts ps opts' "cath" $ \cath -> do + concurrentlyN_ + [ alice <##. "server connected localhost", + do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "#team: connected to server(s)", + do + cath <## "1 contacts connected (use /cs for the list)" + cath <## "#team: connected to server(s)" + ] + bob <# "#team alice> hello" + cath <# "#team alice> hello" + bodiesCount2 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount2 `shouldBe` [[0]] + + alice <##. "server disconnected localhost" + where + tmp = tmpPath ps + serverCfg' = + smpServerCfg + { transports = [("7003", transport @TLS, False)], + serverStoreCfg = persistentServerStoreCfg tmp + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testSharedBatchBody :: HasCallStack => TestParams -> IO () +testSharedBatchBody ps = + withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> do + withSmpServer' serverCfg' $ + withNewTestChatOpts ps opts' "bob" bobProfile $ \bob -> + withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + + alice <##. "server disconnected localhost" + + let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}" + cms = intercalate ", " (map cm [1 .. 300 :: Int]) + alice `send` ("/_send #1 json [" <> cms <> "]") + _ <- getTermLine alice + alice <## "300 messages sent" + + bodiesCount1 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount1 `shouldBe` [[3]] + + withSmpServer' serverCfg' $ + withTestChatOpts ps opts' "bob" $ \bob -> + withTestChatOpts ps opts' "cath" $ \cath -> do + concurrentlyN_ + [ alice <##. "server connected localhost", + do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "#team: connected to server(s)", + do + cath <## "1 contacts connected (use /cs for the list)" + cath <## "#team: connected to server(s)" + ] + forM_ [(1 :: Int) .. 300] $ \i -> do + concurrently_ + (bob <# ("#team alice> message " <> show i)) + (cath <# ("#team alice> message " <> show i)) + bodiesCount2 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount2 `shouldBe` [[0]] + + alice <##. "server disconnected localhost" + where + tmp = tmpPath ps + serverCfg' = + smpServerCfg + { transports = [("7003", transport @TLS, False)], + serverStoreCfg = persistentServerStoreCfg tmp + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testGroupAsync :: HasCallStack => TestParams -> IO () +testGroupAsync ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/g team" alice <## "group #team is created" @@ -1998,8 +2080,8 @@ testGroupAsync tmp = do (bob <## "#team: you joined the group") alice #> "#team hello bob" bob <# "#team alice> hello bob" - withTestChat tmp "alice" $ \alice -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withTestChat ps "alice" $ \alice -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "#team: connected to server(s)" connectUsers alice cath @@ -2017,8 +2099,8 @@ testGroupAsync tmp = do ] alice #> "#team hello cath" cath <# "#team alice> hello cath" - withTestChat tmp "bob" $ \bob -> do - withTestChat tmp "cath" $ \cath -> do + withTestChat ps "bob" $ \bob -> do + withTestChat ps "cath" $ \cath -> do concurrentlyN_ [ do bob <## "1 contacts connected (use /cs for the list)" @@ -2032,8 +2114,8 @@ testGroupAsync tmp = do cath <## "#team: member bob (Bob) is connected" ] threadDelay 500000 - withTestChat tmp "bob" $ \bob -> do - withNewTestChat tmp "dan" danProfile $ \dan -> do + withTestChat ps "bob" $ \bob -> do + withNewTestChat ps "dan" danProfile $ \dan -> do bob <## "2 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" connectUsers bob dan @@ -2051,9 +2133,9 @@ testGroupAsync tmp = do ] threadDelay 1000000 threadDelay 1000000 - withTestChat tmp "alice" $ \alice -> do - withTestChat tmp "cath" $ \cath -> do - withTestChat tmp "dan" $ \dan -> do + withTestChat ps "alice" $ \alice -> do + withTestChat ps "cath" $ \cath -> do + withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do alice <## "2 contacts connected (use /cs for the list)" @@ -2072,10 +2154,10 @@ testGroupAsync tmp = do dan <## "#team: member cath (Catherine) is connected" ] threadDelay 1000000 - withTestChat tmp "alice" $ \alice -> do - withTestChat tmp "bob" $ \bob -> do - withTestChat tmp "cath" $ \cath -> do - withTestChat tmp "dan" $ \dan -> do + withTestChat ps "alice" $ \alice -> do + withTestChat ps "bob" $ \bob -> do + withTestChat ps "cath" $ \cath -> do + withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do alice <## "3 contacts connected (use /cs for the list)" @@ -2118,113 +2200,9 @@ testGroupAsync tmp = do dan <##> cath dan <##> alice -testGroupLink :: HasCallStack => FilePath -> IO () -testGroupLink = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/show link #team" - alice <## "no group link, to create: /create link #team" - alice ##> "/create link #team" - _ <- getGroupLink alice "team" GRMember True - alice ##> "/delete link #team" - alice <## "Group link is deleted - joined members will remain connected." - alice <## "To create a new group link use /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - alice ##> "/show link #team" - _ <- getGroupLink alice "team" GRMember False - alice ##> "/create link #team" - alice <## "you already have link for this group, to show: /show link #team" - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "invited via your group link"), (0, "connected")]) - -- contacts connected via group link are not in chat previews - alice @@@ [("#team", "connected")] - bob @@@ [("#team", "connected")] - alice <##> bob - alice @@@ [("@bob", "hey"), ("#team", "connected")] - - -- user address doesn't interfere - alice ##> "/ad" - cLink <- getContactLink alice True - cath ##> ("/c " <> cLink) - alice <#? cath - alice ##> "/ac cath" - alice <## "cath (Catherine): accepting contact request, you can send messages to contact" - concurrently_ - (cath <## "alice (Alice): contact is connected") - (alice <## "cath (Catherine): contact is connected") - alice <##> cath - - -- third member - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath_1 (Catherine): accepting request to join group #team..." - -- if contact existed it is merged - concurrentlyN_ - [ alice - <### [ "cath_1 (Catherine): contact is connected", - "contact cath_1 is merged into cath", - "use @cath to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - cath - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#team: you joined the group", - "#team: member bob (Bob) is connected" - ], - do - bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - bob <## "#team: new member cath is connected" - ] - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - bob #> "#team hi there" - concurrently_ - (alice <# "#team bob> hi there") - (cath <# "#team bob> hi there") - cath #> "#team hey team" - concurrently_ - (alice <# "#team cath> hey team") - (bob <# "#team cath> hey team") - - threadDelay 100000 - - -- leaving team removes link - alice ##> "/l team" - concurrentlyN_ - [ do - alice <## "#team: you left the group" - alice <## "use /d #team to delete the group", - bob <## "#team: alice left the group", - cath <## "#team: alice left the group" - ] - alice ##> "/show link #team" - alice <## "no group link, to create: /create link #team" - -testGroupLinkDeleteGroupRejoin :: HasCallStack => FilePath -> IO () +testGroupLinkDeleteGroupRejoin :: HasCallStack => TestParams -> IO () testGroupLinkDeleteGroupRejoin = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 alice ##> "/g team" @@ -2236,16 +2214,11 @@ testGroupLinkDeleteGroupRejoin = bob <## "connection request sent!" alice <## "bob (Bob): accepting request to join group #team..." concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", + [ alice <## "#team: bob joined the group", do - bob <## "alice (Alice): contact is connected" + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] - -- use contact so it's not deleted when deleting group - bob <##> alice bob ##> "/l team" concurrentlyN_ [ do @@ -2260,62 +2233,20 @@ testGroupLinkDeleteGroupRejoin = bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], + [ alice <## "#team: bob_1 joined the group", bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", + <### [ "#team: joining the group...", "#team: you joined the group" ] ] alice #> "#team hello" bob <# "#team alice> hello" bob #> "#team hi there" - alice <# "#team bob> hi there" + alice <# "#team bob_1> hi there" -testGroupLinkContactUsed :: HasCallStack => FilePath -> IO () -testGroupLinkContactUsed = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - -- sending/receiving a message marks contact as used - threadDelay 100000 - alice @@@ [("#team", "connected")] - bob @@@ [("#team", "connected")] - alice #> "@bob hello" - bob <# "alice> hello" - threadDelay 500000 - alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") - alice @@@ [("@bob", ""), ("#team", "connected")] - bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") - bob @@@ [("@alice", ""), ("#team", "connected")] - -testGroupLinkIncognitoMembership :: HasCallStack => FilePath -> IO () -testGroupLinkIncognitoMembership = - testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ +testGroupLinkIncognitoJoinInvite :: HasCallStack => TestParams -> IO () +testGroupLinkIncognitoJoinInvite = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- bob connected incognito to alice alice ##> "/c" @@ -2351,41 +2282,25 @@ testGroupLinkIncognitoMembership = cath ##> ("/c " <> gLink) cath <## "connection request sent!" bob <## "cath (Catherine): accepting request to join group #team..." - _ <- getTermLine bob concurrentlyN_ - [ do - bob <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## "use /i cath to print out this incognito profile again" - bob <## "cath invited to group #team via your group link" - bob <## "#team: cath joined the group", + [ bob <## "#team: cath joined the group", do - cath <## (bobIncognito <> ": contact is connected") + cath <## "#team: joining the group..." cath <## "#team: you joined the group" cath <## "#team: member alice (Alice) is connected", do alice <## ("#team: " <> bobIncognito <> " added cath (Catherine) to the group (connecting...)") alice <## "#team: new member cath is connected" ] - bob ?#> "@cath hi, I'm incognito" - cath <# (bobIncognito <> "> hi, I'm incognito") - cath #> ("@" <> bobIncognito <> " hey, I'm cath") - bob ?<# "cath> hey, I'm cath" -- dan joins incognito dan ##> ("/c i " <> gLink) danIncognito <- getTermLine dan dan <## "connection request sent incognito!" bob <## (danIncognito <> ": accepting request to join group #team...") - _ <- getTermLine bob - _ <- getTermLine dan concurrentlyN_ - [ do - bob <## (danIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## ("use /i " <> danIncognito <> " to print out this incognito profile again") - bob <## (danIncognito <> " invited to group #team via your group link") - bob <## ("#team: " <> danIncognito <> " joined the group"), + [ bob <## ("#team: " <> danIncognito <> " joined the group"), do - dan <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> danIncognito) - dan <## ("use /i " <> bobIncognito <> " to print out this incognito profile again") + dan <## "#team: joining the group..." dan <## ("#team: you joined the group incognito as " <> danIncognito) dan <### [ "#team: member alice (Alice) is connected", @@ -2398,10 +2313,6 @@ testGroupLinkIncognitoMembership = cath <## ("#team: " <> bobIncognito <> " added " <> danIncognito <> " to the group (connecting...)") cath <## ("#team: new member " <> danIncognito <> " is connected") ] - bob ?#> ("@" <> danIncognito <> " hi, I'm incognito") - dan ?<# (bobIncognito <> "> hi, I'm incognito") - dan ?#> ("@" <> bobIncognito <> " hey, me too") - bob ?<# (danIncognito <> "> hey, me too") alice #> "#team hello" concurrentlyN_ [ bob ?<# "#team alice> hello", @@ -2427,372 +2338,9 @@ testGroupLinkIncognitoMembership = cath <# ("#team " <> danIncognito <> "> how is it going?") ] -testGroupLinkUnusedHostContactDeleted :: HasCallStack => FilePath -> IO () -testGroupLinkUnusedHostContactDeleted = - testChatCfg2 cfg aliceProfile bobProfile $ - \alice bob -> do - -- create group 1 - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLinkTeam <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLinkTeam) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - -- create group 2 - alice ##> "/g club" - alice <## "group #club is created" - alice <## "to add members use /a club or /create link #club" - alice ##> "/create link #club" - gLinkClub <- getGroupLink alice "club" GRMember True - bob ##> ("/c " <> gLinkClub) - bob <## "connection request sent!" - alice <## "bob_1 (Bob): accepting request to join group #club..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #club via your group link", - EndsWith "joined the group" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#club: you joined the group" - ] - ] - -- list contacts - bob ##> "/contacts" - bob <## "alice (Alice)" - -- delete group 1, host contact and profile are kept - bobLeaveDeleteGroup alice bob "team" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob `hasContactProfiles` ["alice", "bob"] - -- delete group 2, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "club" - threadDelay 3000000 - bob ##> "/contacts" - (bob TestCC -> TestCC -> String -> IO () - bobLeaveDeleteGroup alice bob group = do - bob ##> ("/l " <> group) - concurrentlyN_ - [ do - bob <## ("#" <> group <> ": you left the group") - bob <## ("use /d #" <> group <> " to delete the group"), - alice <## ("#" <> group <> ": bob left the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - -testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => FilePath -> IO () -testGroupLinkIncognitoUnusedHostContactsDeleted = - testChatCfg2 cfg aliceProfile bobProfile $ - \alice bob -> do - bobIncognitoTeam <- createGroupBobIncognito alice bob "team" "alice" - bobIncognitoClub <- createGroupBobIncognito alice bob "club" "alice_1" - bobIncognitoTeam `shouldNotBe` bobIncognitoClub - -- list contacts - bob ##> "/contacts" - bob <## "i alice (Alice)" - bob <## "i alice_1 (Alice)" - bob `hasContactProfiles` ["alice", "alice", "bob", T.pack bobIncognitoTeam, T.pack bobIncognitoClub] - -- delete group 1, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "team" bobIncognitoTeam - threadDelay 3000000 - bob ##> "/contacts" - bob <## "i alice_1 (Alice)" - bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognitoClub] - -- delete group 2, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "club" bobIncognitoClub - threadDelay 3000000 - bob ##> "/contacts" - (bob TestCC -> TestCC -> String -> String -> IO String - createGroupBobIncognito alice bob group bobsAliceContact = do - alice ##> ("/g " <> group) - alice <## ("group #" <> group <> " is created") - alice <## ("to add members use /a " <> group <> " or /create link #" <> group) - alice ##> ("/create link #" <> group) - gLinkTeam <- getGroupLink alice group GRMember True - bob ##> ("/c i " <> gLinkTeam) - bobIncognito <- getTermLine bob - bob <## "connection request sent incognito!" - alice <## (bobIncognito <> ": accepting request to join group #" <> group <> "...") - _ <- getTermLine bob - concurrentlyN_ - [ do - alice <## (bobIncognito <> ": contact is connected") - alice <## (bobIncognito <> " invited to group #" <> group <> " via your group link") - alice <## ("#" <> group <> ": " <> bobIncognito <> " joined the group"), - do - bob <## (bobsAliceContact <> " (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## ("use /i " <> bobsAliceContact <> " to print out this incognito profile again") - bob <## ("#" <> group <> ": you joined the group incognito as " <> bobIncognito) - ] - pure bobIncognito - bobLeaveDeleteGroup :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () - bobLeaveDeleteGroup alice bob group bobIncognito = do - bob ##> ("/l " <> group) - concurrentlyN_ - [ do - bob <## ("#" <> group <> ": you left the group") - bob <## ("use /d #" <> group <> " to delete the group"), - alice <## ("#" <> group <> ": " <> bobIncognito <> " left the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - -testGroupLinkMemberRole :: HasCallStack => FilePath -> IO () -testGroupLinkMemberRole = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team admin" - alice <## "#team: initial role for group member cannot be admin, use member or observer" - alice ##> "/create link #team observer" - gLink <- getGroupLink alice "team" GRObserver True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice ##> "/set link role #team admin" - alice <## "#team: initial role for group member cannot be admin, use member or observer" - alice ##> "/set link role #team member" - _ <- getGroupLink alice "team" GRMember False - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath (Catherine): accepting request to join group #team..." - -- if contact existed it is merged - concurrentlyN_ - [ alice - <### [ "cath (Catherine): contact is connected", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - cath - <### [ "alice (Alice): contact is connected", - "#team: you joined the group", - "#team: member bob (Bob) is connected" - ], - do - bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - bob <## "#team: new member cath is connected" - ] - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - cath #> "#team hello too" - concurrently_ - (alice <# "#team cath> hello too") - (bob <# "#team cath> hello too") - bob ##> "#team hey" - bob <## "#team: you don't have permission to send messages" - alice ##> "/mr #team bob member" - alice <## "#team: you changed the role of bob from observer to member" - concurrently_ - (bob <## "#team: alice changed your role from observer to member") - (cath <## "#team: alice changed the role of bob from observer to member") - bob #> "#team hey now" - concurrently_ - (alice <# "#team bob> hey now") - (cath <# "#team bob> hey now") - -testGroupLinkLeaveDelete :: HasCallStack => FilePath -> IO () -testGroupLinkLeaveDelete = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - connectUsers alice bob - connectUsers cath bob - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob_1 (Bob): accepting request to join group #team..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#team: you joined the group" - ] - ] - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath (Catherine): accepting request to join group #team..." - concurrentlyN_ - [ alice - <### [ "cath (Catherine): contact is connected", - "cath invited to group #team via your group link", - "#team: cath joined the group" - ], - cath - <### [ "alice (Alice): contact is connected", - "#team: you joined the group", - "#team: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - bob - <### [ "#team: alice added cath_1 (Catherine) to the group (connecting...)", - "#team: new member cath_1 is connected", - "contact cath_1 is merged into cath", - "use @cath to send messages" - ] - ] - bob ##> "/l team" - concurrentlyN_ - [ do - bob <## "#team: you left the group" - bob <## "use /d #team to delete the group", - alice <## "#team: bob left the group", - cath <## "#team: bob left the group" - ] - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - bob ##> "/d #team" - bob <## "#team: you deleted the group" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - -testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () -testPlanGroupLinkOkKnown = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: ok to connect" - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice #> "#team hi" - bob <# "#team alice> hi" - bob #> "#team hey" - alice <# "#team bob> hey" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - bob ##> ("/c " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - -testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () -testPlanHostContactDeletedGroupLinkKnown = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice #> "#team hi" - bob <# "#team alice> hi" - bob #> "#team hey" - alice <# "#team bob> hey" - - alice <##> bob - threadDelay 500000 - bob ##> "/d alice" - bob <## "alice: contact is deleted" - alice <## "bob (Bob) deleted contact with you" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - bob ##> ("/c " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - -testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () -testPlanGroupLinkOwn tmp = - withNewTestChatCfg tmp (mkCfgGroupLinkViaContact testCfgSlow) "alice" aliceProfile $ \alice -> do +testPlanGroupLinkOwn :: HasCallStack => TestParams -> IO () +testPlanGroupLinkOwn ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" @@ -2811,18 +2359,14 @@ testPlanGroupLinkOwn tmp = alice <## "connection request sent!" alice <## "alice_1 (Alice): accepting request to join group #team..." alice - <### [ "alice_1 (Alice): contact is connected", - "alice_1 invited to group #team via your group link", - "#team: alice_1 joined the group", - "alice_2 (Alice): contact is connected", - "#team_1: you joined the group", - "contact alice_2 is merged into alice_1", - "use @alice_1 to send messages" + <### [ "#team: alice_1 joined the group", + "#team_1: joining the group...", + "#team_1: you joined the group" ] alice `send` "#team 1" alice <### [ WithTime "#team 1", - WithTime "#team_1 alice_1> 1" + WithTime "#team_1 alice_2> 1" ] alice `send` "#team_1 2" alice @@ -2836,71 +2380,9 @@ testPlanGroupLinkOwn tmp = alice ##> ("/_connect plan 1 " <> gLinkSchema2) alice <## "group link: own link for group #team" - -- group works if merged contact is deleted - alice ##> "/d alice_1" - alice <## "alice_1: contact is deleted" - - alice `send` "#team 3" - alice - <### [ WithTime "#team 3", - WithTime "#team_1 alice_1> 3" - ] - alice `send` "#team_1 4" - alice - <### [ WithTime "#team_1 4", - WithTime "#team alice_1> 4" - ] - -testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () -testPlanGroupLinkConnecting tmp = do - -- gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - getGroupLink alice "team" GRMember True - -- withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - threadDelay 100000 - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: connecting, allowed to reconnect" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: connecting, allowed to reconnect" - - threadDelay 100000 - -- withTestChatCfg tmp cfg "alice" $ \alice -> do - withTestChatCfg tmp cfg "alice" $ \alice -> do - alice - <### [ "1 group links active", - "#team: group is empty", - "bob (Bob): accepting request to join group #team..." - ] - -- withTestChatCfg tmp cfg "bob" $ \bob -> do - withTestChatCfg tmp cfg "bob" $ \bob -> do - threadDelay 500000 - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: connecting" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: connecting" - - bob ##> ("/c " <> gLink) - bob <## "group link: connecting" - where - cfg = mkCfgGroupLinkViaContact testCfgSlow - -testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () +testPlanGroupLinkLeaveRejoin :: HasCallStack => TestParams -> IO () testPlanGroupLinkLeaveRejoin = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 alice ##> "/g team" @@ -2913,12 +2395,9 @@ testPlanGroupLinkLeaveRejoin = bob <## "connection request sent!" alice <## "bob (Bob): accepting request to join group #team..." concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", + [ alice <## "#team: bob joined the group", do - bob <## "alice (Alice): contact is connected" + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] @@ -2955,25 +2434,17 @@ testPlanGroupLinkLeaveRejoin = bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], + [ alice <## "#team: bob_1 joined the group", bob - <### [ "alice_1 (Alice): contact is connected", - "#team_1: you joined the group", - "contact alice_1 is merged into alice", - "use @alice to send messages" + <### [ "#team_1: joining the group...", + "#team_1: you joined the group" ] ] alice #> "#team hi" - bob <# "#team_1 alice> hi" + bob <# "#team_1 alice_1> hi" bob #> "#team_1 hey" - alice <# "#team bob> hey" + alice <# "#team bob_1> hey" bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: known group #team_1" @@ -2987,8 +2458,8 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" -testGroupLinkNoContact :: HasCallStack => FilePath -> IO () -testGroupLinkNoContact = +testGroupLink :: HasCallStack => TestParams -> IO () +testGroupLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do threadDelay 100000 @@ -3025,16 +2496,34 @@ testGroupLinkNoContact = bob #> "#team hi there" alice <# "#team bob> hi there" + -- user address doesn't interfere + alice ##> "/ad" + cLink <- getContactLink alice True + cath ##> ("/c " <> cLink) + alice <#? cath + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request, you can send messages to contact" + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice <##> cath + + -- third member cath ##> ("/c " <> gLink) cath <## "connection request sent!" concurrentlyN_ [ do - alice <## "cath (Catherine): accepting request to join group #team..." - alice <## "#team: cath joined the group", - do - cath <## "#team: joining the group..." - cath <## "#team: you joined the group" - cath <## "#team: member bob (Bob) is connected", + alice <## "cath_1 (Catherine): accepting request to join group #team..." + alice <## "#team: cath_1 joined the group" + alice <## "contact and member are merged: cath, #team cath_1" + alice <## "use @cath to send messages", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member bob (Bob) is connected", + "contact and member are merged: alice, #team alice_1", + "use @alice to send messages" + ], do bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" @@ -3048,8 +2537,28 @@ testGroupLinkNoContact = alice <# "#team bob> hi cath" cath <# "#team bob> hi cath" -testGroupLinkNoContactInviteesWereConnected :: HasCallStack => FilePath -> IO () -testGroupLinkNoContactInviteesWereConnected = + -- leaving group removes link + alice ##> "/l team" + concurrentlyN_ + [ do + alice <## "#team: you left the group" + alice <## "use /d #team to delete the group", + bob <## "#team: alice left the group", + cath <## "#team: alice left the group" + ] + alice ##> "/show link #team" + alice <## "no group link, to create: /create link #team" + + -- deleting group keeps contacts + alice ##> "/contacts" + alice <## "cath (Catherine)" + alice ##> "/d #team" + alice <## "#team: you deleted the group" + alice ##> "/contacts" + alice <## "cath (Catherine)" + +testGroupLinkInviteesWereConnected :: HasCallStack => TestParams -> IO () +testGroupLinkInviteesWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers bob cath @@ -3120,8 +2629,8 @@ testGroupLinkNoContactInviteesWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactAllMembersWereConnected :: HasCallStack => FilePath -> IO () -testGroupLinkNoContactAllMembersWereConnected = +testGroupLinkAllMembersWereConnected :: HasCallStack => TestParams -> IO () +testGroupLinkAllMembersWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -3211,8 +2720,8 @@ testGroupLinkNoContactAllMembersWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactMemberRole :: HasCallStack => FilePath -> IO () -testGroupLinkNoContactMemberRole = +testGroupLinkMemberRole :: HasCallStack => TestParams -> IO () +testGroupLinkMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do threadDelay 100000 @@ -3249,7 +2758,7 @@ testGroupLinkNoContactMemberRole = bob <## "#team: you don't have permission to send messages" alice ##> "/mr #team bob member" - alice <## "#team: you changed the role of bob from observer to member" + alice <## "#team: you changed the role of bob to member" bob <## "#team: alice changed your role from observer to member" bob #> "#team hey now" @@ -3279,7 +2788,7 @@ testGroupLinkNoContactMemberRole = cath <## "#team: you don't have permission to send messages" alice ##> "/mr #team cath admin" - alice <## "#team: you changed the role of cath from observer to admin" + alice <## "#team: you changed the role of cath to admin" cath <## "#team: alice changed your role from observer to admin" bob <## "#team: alice changed the role of cath from observer to admin" @@ -3288,12 +2797,12 @@ testGroupLinkNoContactMemberRole = bob <# "#team cath> hey" cath ##> "/mr #team bob admin" - cath <## "#team: you changed the role of bob from member to admin" + cath <## "#team: you changed the role of bob to admin" bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" -testGroupLinkNoContactHostIncognito :: HasCallStack => FilePath -> IO () -testGroupLinkNoContactHostIncognito = +testGroupLinkHostIncognito :: HasCallStack => TestParams -> IO () +testGroupLinkHostIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/g i team" @@ -3325,8 +2834,8 @@ testGroupLinkNoContactHostIncognito = bob #> "#team hi there" alice ?<# "#team bob> hi there" -testGroupLinkNoContactInviteeIncognito :: HasCallStack => FilePath -> IO () -testGroupLinkNoContactInviteeIncognito = +testGroupLinkInviteeIncognito :: HasCallStack => TestParams -> IO () +testGroupLinkInviteeIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 @@ -3359,8 +2868,8 @@ testGroupLinkNoContactInviteeIncognito = bob ?#> "#team hi there" alice <# ("#team " <> bobIncognito <> "> hi there") -testGroupLinkNoContactHostProfileReceived :: HasCallStack => FilePath -> IO () -testGroupLinkNoContactHostProfileReceived = +testGroupLinkHostProfileReceived :: HasCallStack => TestParams -> IO () +testGroupLinkHostProfileReceived = testChat2 aliceProfile bobProfile $ \alice bob -> do let profileImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" @@ -3387,8 +2896,8 @@ testGroupLinkNoContactHostProfileReceived = aliceImage <- getProfilePictureByName bob "alice" aliceImage `shouldBe` Just profileImage -testGroupLinkNoContactExistingContactMerged :: HasCallStack => FilePath -> IO () -testGroupLinkNoContactExistingContactMerged = +testGroupLinkExistingContactMerged :: HasCallStack => TestParams -> IO () +testGroupLinkExistingContactMerged = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -3430,8 +2939,413 @@ testGroupLinkNoContactExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" -testPlanGroupLinkNoContactKnown :: HasCallStack => FilePath -> IO () -testPlanGroupLinkNoContactKnown = +testGLinkRejectBlockedName :: HasCallStack => TestParams -> IO () +testGLinkRejectBlockedName = + testChatCfg2 cfg aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): rejecting request to join group #team, reason: GRRBlockedName" + bob <## "#team: joining the group..." + bob <## "#team: join rejected, reason: GRRBlockedName" + + threadDelay 100000 + + alice `hasContactProfiles` ["alice"] + memCount <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM group_members" :: IO [[Int]] + memCount `shouldBe` [[1]] + + -- rejected member can't send messages to group + bob ##> "#team hello" + bob <## "bad chat command: not current member" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Left GRRBlockedName)}} + +testGLinkApproveMember :: HasCallStack => TestParams -> IO () +testGLinkApproveMember = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath connected and pending approval, use /_accept member #1 3 to accept member", + do + cath <## "#team: joining the group..." + cath <## "#team: you joined the group, pending approval" + ] + + -- pending member doesn't see messages sent in group + alice #> "#team hi group" + bob <# "#team alice> hi group" + + bob #> "#team hey" + alice <# "#team bob> hey" + + -- pending member can't send messages to group + cath ##> "#team hello" + cath <## "bad chat command: not current member" + + -- pending member and host can send messages to each other + alice ##> "/_send #1(_support:3) text send me proofs" + alice <# "#team (support: cath) send me proofs" + cath <# "#team (support) alice> send me proofs" + + cath ##> "/_send #1(_support) text proofs" + cath <# "#team (support) proofs" + alice <# "#team (support: cath) cath> proofs" + + -- accept member + alice ##> "/accept member #team cath" + concurrentlyN_ + [ alice <## "#team: cath accepted", + cath + <### [ "#team: you joined the group", + WithTime "#team alice> hi group [>>]", + WithTime "#team bob> hey [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + + alice #> "#team welcome cath" + [bob, cath] *<# "#team alice> welcome cath" + + bob #> "#team hi cath" + [alice, cath] *<# "#team bob> hi cath" + + cath #> "#team hi group" + [alice, bob] *<# "#team cath> hi group" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} + +testGLinkReviewMember :: HasCallStack => TestParams -> IO () +testGLinkReviewMember = + testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ + \alice bob cath dan eve -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRModerator) (dan, GRModerator) + + alice ##> "/set admission review #team all" + alice <## "changed member admission rules" + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "changed member admission rules", + do + cath <## "alice updated group #team:" + cath <## "changed member admission rules", + do + dan <## "alice updated group #team:" + dan <## "changed member admission rules" + ] + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + alice <## "eve (Eve): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: eve connected and pending review", + eve + <### [ "#team: alice accepted you to the group, pending review", + "#team: joining the group...", + "#team: you joined the group, connecting to group moderators for admission to group", + "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + do + cath <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + cath <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member", + do + dan <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + dan <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member" + ] + + -- pending member doesn't see messages sent in group + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + cath #> "#team 3" + [alice, bob, dan] *<# "#team cath> 3" + + dan #> "#team 4" + [alice, bob, cath] *<# "#team dan> 4" + + (eve "#team hello" + eve <## "bad chat command: not current member" + + -- pending member and moderators can send messages to each other + alice ##> "/_send #1(_support:5) text 5" + alice <# "#team (support: eve) 5" + [cath, dan] *<# "#team (support: eve) alice> 5" + eve <# "#team (support) alice> 5" + + cath ##> "/_send #1(_support:5) text 6" + cath <# "#team (support: eve) 6" + [alice, dan] *<# "#team (support: eve) cath> 6" + eve <# "#team (support) cath> 6" + + dan ##> "/_send #1(_support:5) text 7" + dan <# "#team (support: eve) 7" + [alice, cath] *<# "#team (support: eve) dan> 7" + eve <# "#team (support) dan> 7" + + eve ##> "/_send #1(_support) text 8" + eve <# "#team (support) 8" + [alice, cath, dan] *<# "#team (support: eve) eve> 8" + + (bob "/_delete member chat #1 5" + alice <## "bad chat command: member is pending" + + -- accept member + dan ##> "/_accept member #1 5 member" + concurrentlyN_ + [ dan <## "#team: eve accepted", + alice <## "#team: dan accepted eve to the group (will introduce remaining members)", + cath <## "#team: dan accepted eve to the group", + eve + <### [ "#team: you joined the group", + WithTime "#team alice> 1 [>>]", + WithTime "#team bob> 2 [>>]", + WithTime "#team cath> 3 [>>]", + WithTime "#team dan> 4 [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added eve (Eve) to the group (connecting...)" + bob <## "#team: new member eve is connected" + ] + + alice #> "#team 9" + [bob, cath, dan, eve] *<# "#team alice> 9" + + bob #> "#team 10" + [alice, cath, dan, eve] *<# "#team bob> 10" + + cath #> "#team 11" + [alice, bob, dan, eve] *<# "#team cath> 11" + + dan #> "#team 12" + [alice, bob, cath, eve] *<# "#team dan> 12" + + eve #> "#team 13" + [alice, bob, cath, dan] *<# "#team eve> 13" + +testGLinkApproveThenReviewMember :: HasCallStack => TestParams -> IO () +testGLinkApproveThenReviewMember = + testChatCfg5 cfg aliceProfile bobProfile cathProfile danProfile eveProfile $ + \alice bob cath dan eve -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRModerator) (dan, GRModerator) + + alice ##> "/set admission review #team all" + alice <## "changed member admission rules" + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "changed member admission rules", + do + cath <## "alice updated group #team:" + cath <## "changed member admission rules", + do + dan <## "alice updated group #team:" + dan <## "changed member admission rules" + ] + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + alice <## "eve (Eve): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: eve connected and pending approval, use /_accept member #1 5 to accept member", + do + eve <## "#team: joining the group..." + eve <## "#team: you joined the group, pending approval" + ] + + -- pending member doesn't see messages sent in group + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + cath #> "#team 3" + [alice, bob, dan] *<# "#team cath> 3" + + dan #> "#team 4" + [alice, bob, cath] *<# "#team dan> 4" + + (eve "#team hello" + eve <## "bad chat command: not current member" + + -- pending member and host can send messages to each other + alice ##> "/_send #1(_support:5) text 5" + alice <# "#team (support: eve) 5" + eve <# "#team (support) alice> 5" + + eve ##> "/_send #1(_support) text 6" + eve <# "#team (support) 6" + alice <# "#team (support: eve) eve> 6" + + (bob "/_accept member #1 5 member" + concurrentlyN_ + [ alice <## "#team: eve accepted and pending review (will introduce moderators)", + eve + <### [ "#team: alice accepted you to the group, pending review", + "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + do + cath <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + cath <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member", + do + dan <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + dan <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member" + ] + + -- pending member doesn't see messages sent in group + alice #> "#team 7" + [bob, cath, dan] *<# "#team alice> 7" + + bob #> "#team 8" + [alice, cath, dan] *<# "#team bob> 8" + + cath #> "#team 9" + [alice, bob, dan] *<# "#team cath> 9" + + dan #> "#team 10" + [alice, bob, cath] *<# "#team dan> 10" + + (eve "#team hello" + eve <## "bad chat command: not current member" + + -- pending member and moderators can send messages to each other + alice ##> "/_send #1(_support:5) text 11" + alice <# "#team (support: eve) 11" + [cath, dan] *<# "#team (support: eve) alice> 11" + eve <# "#team (support) alice> 11" + + cath ##> "/_send #1(_support:5) text 12" + cath <# "#team (support: eve) 12" + [alice, dan] *<# "#team (support: eve) cath> 12" + eve <# "#team (support) cath> 12" + + dan ##> "/_send #1(_support:5) text 13" + dan <# "#team (support: eve) 13" + [alice, cath] *<# "#team (support: eve) dan> 13" + eve <# "#team (support) dan> 13" + + eve ##> "/_send #1(_support) text 14" + eve <# "#team (support) 14" + [alice, cath, dan] *<# "#team (support: eve) eve> 14" + + (bob "/_accept member #1 5 member" + concurrentlyN_ + [ dan <## "#team: eve accepted", + alice <## "#team: dan accepted eve to the group (will introduce remaining members)", + cath <## "#team: dan accepted eve to the group", + eve + <### [ "#team: you joined the group", + WithTime "#team alice> 1 [>>]", + WithTime "#team bob> 2 [>>]", + WithTime "#team cath> 3 [>>]", + WithTime "#team dan> 4 [>>]", + WithTime "#team alice> 7 [>>]", + WithTime "#team bob> 8 [>>]", + WithTime "#team cath> 9 [>>]", + WithTime "#team dan> 10 [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added eve (Eve) to the group (connecting...)" + bob <## "#team: new member eve is connected" + ] + + alice #> "#team 15" + [bob, cath, dan, eve] *<# "#team alice> 15" + + bob #> "#team 16" + [alice, cath, dan, eve] *<# "#team bob> 16" + + cath #> "#team 17" + [alice, bob, dan, eve] *<# "#team cath> 17" + + dan #> "#team 18" + [alice, bob, cath, eve] *<# "#team dan> 18" + + eve #> "#team 19" + [alice, bob, cath, dan] *<# "#team eve> 19" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} + +testGLinkDeletePendingApprovalMember :: HasCallStack => TestParams -> IO () +testGLinkDeletePendingApprovalMember = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath connected and pending approval, use /_accept member #1 3 to accept member", + do + cath <## "#team: joining the group..." + cath <## "#team: you joined the group, pending approval" + ] + + alice ##> "/rm team cath" + alice <## "#team: you removed cath from the group" + cath <## "#team: alice removed you from the group" + cath <## "use /d #team to delete the group" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} + +testPlanGroupLinkKnown :: HasCallStack => TestParams -> IO () +testPlanGroupLinkKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 @@ -3467,16 +3381,16 @@ testPlanGroupLinkNoContactKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnecting :: HasCallStack => FilePath -> IO () -testPlanGroupLinkNoContactConnecting tmp = do - gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnecting ps = do + gLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -3490,13 +3404,13 @@ testPlanGroupLinkNoContactConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do threadDelay 500000 bob <## "#team: joining the group..." bob <## "#team: you joined the group" @@ -3514,16 +3428,16 @@ testPlanGroupLinkNoContactConnecting tmp = do bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnectingSlow :: HasCallStack => FilePath -> IO () -testPlanGroupLinkNoContactConnectingSlow tmp = do - gLink <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testPlanGroupLinkConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnectingSlow ps = do + gLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -3537,13 +3451,13 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 bob <## "#team: joining the group..." @@ -3558,17 +3472,17 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob <## "group link: connecting to group #team" #if !defined(dbPostgres) -testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () -testGroupMsgDecryptError tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupMsgDecryptError :: HasCallStack => TestParams -> IO () +testGroupMsgDecryptError ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" bob #> "#team hey" alice <# "#team bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" alice #> "#team hello again" @@ -3577,10 +3491,10 @@ testGroupMsgDecryptError tmp = bob #> "#team received!" alice <# "#team bob> received!" -setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> IO () -setupDesynchronizedRatchet tmp alice = do +setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () +setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" alice #> "#team 1" @@ -3591,7 +3505,7 @@ setupDesynchronizedRatchet tmp alice = do bob <# "#team alice> 3" bob #> "#team 4" alice <# "#team bob> 4" - withTestChat tmp "bob_old" $ \bob -> do + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob ##> "/sync #team alice" @@ -3605,21 +3519,22 @@ setupDesynchronizedRatchet tmp alice = do bob ##> "/tail #team 1" bob <# "#team alice> decryption error, possibly due to the device change (header, 3 messages)" where + tmp = tmpPath ps copyDb from to = do copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) -testGroupSyncRatchet :: HasCallStack => FilePath -> IO () -testGroupSyncRatchet tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupSyncRatchet :: HasCallStack => TestParams -> IO () +testGroupSyncRatchet ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" bob #> "#team hey" alice <# "#team bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob `send` "#team 1" @@ -3644,10 +3559,10 @@ testGroupSyncRatchet tmp = bob #> "#team received!" alice <# "#team bob> received!" -testGroupSyncRatchetCodeReset :: HasCallStack => FilePath -> IO () -testGroupSyncRatchetCodeReset tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupSyncRatchetCodeReset :: HasCallStack => TestParams -> IO () +testGroupSyncRatchetCodeReset ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" @@ -3664,8 +3579,8 @@ testGroupSyncRatchetCodeReset tmp = -- connection verified bob ##> "/i #team alice" aliceInfo bob True - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob ##> "/sync #team alice" @@ -3703,7 +3618,7 @@ testGroupSyncRatchetCodeReset tmp = | otherwise = "connection not verified, use /code command to see security code" #endif -testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () +testSetGroupMessageReactions :: HasCallStack => TestParams -> IO () testSetGroupMessageReactions = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3775,11 +3690,11 @@ testSetGroupMessageReactions = cath <# "#team alice> hi" cath <## " 👍 1" -testSendGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () -testSendGroupDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testSendGroupDeliveryReceipts :: HasCallStack => TestParams -> IO () +testSendGroupDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do -- turn off contacts receipts for tests alice ##> "/_set receipts contacts 1 off" alice <## "ok" @@ -3805,11 +3720,11 @@ testSendGroupDeliveryReceipts tmp = where cfg = testCfg {showReceipts = True} -testConfigureGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () -testConfigureGroupDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testConfigureGroupDeliveryReceipts :: HasCallStack => TestParams -> IO () +testConfigureGroupDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do -- turn off contacts receipts for tests alice ##> "/_set receipts contacts 1 off" alice <## "ok" @@ -3849,14 +3764,10 @@ testConfigureGroupDeliveryReceipts tmp = [ alice <## "#club: cath joined the group", do cath <## "#club: you joined the group" - cath <## "#club: member bob_1 (Bob) is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages", + cath <## "#club: member bob_1 (Bob) is connected", do bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)" bob <## "#club: new member cath_1 is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages" ] threadDelay 1000000 @@ -3933,56 +3844,35 @@ testConfigureGroupDeliveryReceipts tmp = receipt bob alice cath "team" "25" noReceipt bob alice cath "club" "26" where - cfg = mkCfgCreateGroupDirect $ testCfg {showReceipts = True} + cfg = testCfg {showReceipts = True} receipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 % ("#" <> gName <> " " <> msg) cc1 ⩗ ("#" <> gName <> " " <> msg) partialReceipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 % ("#" <> gName <> " " <> msg) noReceipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> FilePath -> IO () -testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = - withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do - createGroup3 "team" alice bob cath - if noDirectConns - then contactsDontExist bob cath - else contactsExist bob cath - where - contactsDontExist bob cath = do +testNoGroupDirectConns :: HasCallStack => TestParams -> IO () +testNoGroupDirectConns = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath bob ##> "/contacts" bob <## "alice (Alice)" cath ##> "/contacts" cath <## "alice (Alice)" - contactsExist bob cath = do - bob ##> "/contacts" - bob - <### [ "alice (Alice)", - "cath (Catherine)" - ] - cath ##> "/contacts" - cath - <### [ "alice (Alice)", - "bob (Bob)" - ] - bob <##> cath -testNoDirectDifferentLDNs :: HasCallStack => FilePath -> IO () +testNoDirectDifferentLDNs :: HasCallStack => TestParams -> IO () testNoDirectDifferentLDNs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4052,7 +3942,7 @@ testNoDirectDifferentLDNs = bob <# ("#" <> gName <> " " <> cathLDN <> "> hey") ] -testMergeMemberExistingContact :: HasCallStack => FilePath -> IO () +testMergeMemberExistingContact :: HasCallStack => TestParams -> IO () testMergeMemberExistingContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4099,7 +3989,7 @@ testMergeMemberExistingContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactExistingMember :: HasCallStack => FilePath -> IO () +testMergeContactExistingMember :: HasCallStack => TestParams -> IO () testMergeContactExistingMember = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4130,7 +4020,7 @@ testMergeContactExistingMember = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () +testMergeContactMultipleMembers :: HasCallStack => TestParams -> IO () testMergeContactMultipleMembers = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4168,9 +4058,9 @@ testMergeContactMultipleMembers = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeGroupLinkHostMultipleContacts :: HasCallStack => TestParams -> IO () testMergeGroupLinkHostMultipleContacts = - testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ + testChat2 bobProfile cathProfile $ \bob cath -> do connectUsers bob cath @@ -4195,16 +4085,14 @@ testMergeGroupLinkHostMultipleContacts = bob <## "cath_2 (Catherine): accepting request to join group #party..." concurrentlyN_ [ bob - <### [ "cath_2 (Catherine): contact is connected", - EndsWith "invited to group #party via your group link", - EndsWith "joined the group", - StartsWith "contact cath_2 is merged into cath", + <### [ EndsWith "joined the group", + "contact and member are merged: cath, #party cath_2", StartsWith "use @cath" ], cath - <### [ "bob_2 (Bob): contact is connected", + <### [ "#party: joining the group...", "#party: you joined the group", - StartsWith "contact bob_2 is merged into bob", + "contact and member are merged: bob, #party bob_2", StartsWith "use @bob" ] ] @@ -4217,7 +4105,7 @@ testMergeGroupLinkHostMultipleContacts = bob `hasContactProfiles` ["bob", "cath", "cath"] cath `hasContactProfiles` ["cath", "bob", "bob"] -testMemberContactMessage :: HasCallStack => FilePath -> IO () +testMemberContactMessage :: HasCallStack => TestParams -> IO () testMemberContactMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4284,7 +4172,7 @@ testMemberContactMessage = cath #$> ("/_get chat #1 count=1", chat, [(0, "started direct connection with you")]) bob <##> cath -testMemberContactNoMessage :: HasCallStack => FilePath -> IO () +testMemberContactNoMessage :: HasCallStack => TestParams -> IO () testMemberContactNoMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4305,7 +4193,7 @@ testMemberContactNoMessage = cath #$> ("/_get chat #1 count=1", chat, [(0, "started direct connection with you")]) bob <##> cath -testMemberContactProhibitedContactExists :: HasCallStack => FilePath -> IO () +testMemberContactProhibitedContactExists :: HasCallStack => TestParams -> IO () testMemberContactProhibitedContactExists = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4318,7 +4206,7 @@ testMemberContactProhibitedContactExists = alice <# "@bob hi" bob <# "alice> hi" -testMemberContactProhibitedRepeatInv :: HasCallStack => FilePath -> IO () +testMemberContactProhibitedRepeatInv :: HasCallStack => TestParams -> IO () testMemberContactProhibitedRepeatInv = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4345,11 +4233,11 @@ testMemberContactProhibitedRepeatInv = bob <##> cath -testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () -testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testMemberContactInvitedConnectionReplaced :: HasCallStack => TestParams -> IO () +testMemberContactInvitedConnectionReplaced ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -4377,20 +4265,20 @@ testMemberContactInvitedConnectionReplaced tmp = do items <- chat <$> getTermLine bob items `shouldContain` [(0, "security code changed")] - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do subscriptions bob 1 checkConnectionsWork alice bob - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do subscriptions alice 2 - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do subscriptions bob 1 checkConnectionsWork alice bob - withTestChat tmp "cath" $ \cath -> do + withTestChat ps "cath" $ \cath -> do subscriptions cath 1 -- group messages work @@ -4416,9 +4304,9 @@ testMemberContactInvitedConnectionReplaced tmp = do alice @@@ [("@bob", "hey"), ("@cath", "sent invitation to join group team as admin"), ("#team", "connected")] bob @@@ [("@alice", "hey"), ("#team", "started direct connection with you")] -testMemberContactIncognito :: HasCallStack => FilePath -> IO () +testMemberContactIncognito :: HasCallStack => TestParams -> IO () testMemberContactIncognito = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do -- create group, bob joins incognito threadDelay 100000 @@ -4431,15 +4319,10 @@ testMemberContactIncognito = bobIncognito <- getTermLine bob bob <## "connection request sent incognito!" alice <## (bobIncognito <> ": accepting request to join group #team...") - _ <- getTermLine bob concurrentlyN_ - [ do - alice <## (bobIncognito <> ": contact is connected") - alice <## (bobIncognito <> " invited to group #team via your group link") - alice <## ("#team: " <> bobIncognito <> " joined the group"), + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), do - bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## "use /i alice to print out this incognito profile again" + bob <## "#team: joining the group..." bob <## ("#team: you joined the group incognito as " <> bobIncognito) ] -- cath joins incognito @@ -4447,15 +4330,10 @@ testMemberContactIncognito = cathIncognito <- getTermLine cath cath <## "connection request sent incognito!" alice <## (cathIncognito <> ": accepting request to join group #team...") - _ <- getTermLine cath concurrentlyN_ - [ do - alice <## (cathIncognito <> ": contact is connected") - alice <## (cathIncognito <> " invited to group #team via your group link") - alice <## ("#team: " <> cathIncognito <> " joined the group"), + [ alice <## ("#team: " <> cathIncognito <> " joined the group"), do - cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) - cath <## "use /i alice to print out this incognito profile again" + cath <## "#team: joining the group..." cath <## ("#team: you joined the group incognito as " <> cathIncognito) cath <## ("#team: member " <> bobIncognito <> " is connected"), do @@ -4516,7 +4394,7 @@ testMemberContactIncognito = bob ?<# ("#team " <> cathIncognito <> "> hey") ] -testMemberContactProfileUpdate :: HasCallStack => FilePath -> IO () +testMemberContactProfileUpdate :: HasCallStack => TestParams -> IO () testMemberContactProfileUpdate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4584,13 +4462,13 @@ testMemberContactProfileUpdate = alice <# "#team kate> hello there" bob <# "#team kate> hello there" -- updated profile -testRecreateMemberContactManyGroups :: HasCallStack => FilePath -> IO () +testRecreateMemberContactManyGroups :: HasCallStack => TestParams -> IO () testRecreateMemberContactManyGroups = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - createGroup2' "team" alice bob False - createGroup2' "club" alice bob False + createGroup2' "team" alice (bob, GRAdmin) False + createGroup2' "club" alice (bob, GRAdmin) False -- alice can message bob via team and via club alice ##> "@#team bob 1" @@ -4617,6 +4495,12 @@ testRecreateMemberContactManyGroups = bob ##> "/d alice" bob <## "alice: contact is deleted" + -- group messages work + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + -- alice creates member contact with bob alice ##> "@#team bob hi" alice @@ -4651,7 +4535,7 @@ testRecreateMemberContactManyGroups = bob <# "@alice 4" alice <# "bob> 4" -testGroupMsgForward :: HasCallStack => FilePath -> IO () +testGroupMsgForward :: HasCallStack => TestParams -> IO () testGroupMsgForward = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4679,6 +4563,64 @@ testGroupMsgForward = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +testGroupMsgForwardReport :: HasCallStack => TestParams -> IO () +testGroupMsgForwardReport = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + alice ##> "/mr team bob moderator" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob to moderator", + bob <## "#team: alice changed your role from admin to moderator", + cath <## "#team: alice changed the role of bob from admin to moderator" + ] + + alice ##> "/mr team cath member" + concurrentlyN_ + [ alice <## "#team: you changed the role of cath to member", + bob <## "#team: alice changed the role of cath from admin to member", + cath <## "#team: alice changed your role from admin to member" + ] + cath ##> "/report #team content hi there" + cath <# "#team (support) > bob hi there" + cath <## " report content" + concurrentlyN_ + [ do + alice <# "#team (support: cath) cath> > bob hi there" + alice <## " report content", + do + bob <# "#team (support: cath) cath!> > bob hi there [>>]" + bob <## " report content [>>]" + ] + + alice ##> "/mr team bob member" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob to member", + bob <## "#team: alice changed your role from moderator to member", + cath <## "#team: alice changed the role of bob from moderator to member" + ] + + cath ##> "/report #team content hi there" + cath <# "#team (support) > bob hi there" + cath <## " report content" + concurrentlyN_ + [ do + alice <# "#team (support: cath) cath> > bob hi there" + alice <## " report content", + (bob "#team hey team" + alice <# "#team cath> hey team" + bob <# "#team cath> hey team [>>]" + setupGroupForwarding3 :: String -> TestCC -> TestCC -> TestCC -> IO () setupGroupForwarding3 gName alice bob cath = do createGroup3 gName alice bob cath @@ -4691,7 +4633,7 @@ setupGroupForwarding3 gName alice bob cath = do void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" -testGroupMsgForwardDeduplicate :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeduplicate :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeduplicate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4728,7 +4670,7 @@ testGroupMsgForwardDeduplicate = cath <#. "#team bob> hi there" cath <# "#team hey team" -testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () +testGroupMsgForwardEdit :: HasCallStack => TestParams -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4751,7 +4693,7 @@ testGroupMsgForwardEdit = cath ##> "/tail #team 1" cath <# "#team bob> hello there [>>]" -testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO () +testGroupMsgForwardReaction :: HasCallStack => TestParams -> IO () testGroupMsgForwardReaction = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4768,11 +4710,12 @@ testGroupMsgForwardReaction = bob <# "#team cath> > bob hi there" bob <## " + 👍" -testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeletion :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeletion = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" @@ -4783,7 +4726,7 @@ testGroupMsgForwardDeletion = alice <# "#team bob> [marked deleted] hi there" cath <# "#team bob> [marked deleted] hi there" -- TODO show as forwarded -testGroupMsgForwardFile :: HasCallStack => FilePath -> IO () +testGroupMsgForwardFile :: HasCallStack => TestParams -> IO () testGroupMsgForwardFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -4808,18 +4751,18 @@ testGroupMsgForwardFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () +testGroupMsgForwardChangeRole :: HasCallStack => TestParams -> IO () testGroupMsgForwardChangeRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath cath ##> "/mr #team bob member" - cath <## "#team: you changed the role of bob from admin to member" + cath <## "#team: you changed the role of bob to member" alice <## "#team: cath changed the role of bob from admin to member" bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded -testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO () +testGroupMsgForwardNewMember :: HasCallStack => TestParams -> IO () testGroupMsgForwardNewMember = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -4860,7 +4803,7 @@ testGroupMsgForwardNewMember = "dan (Daniel): member" ] -testGroupMsgForwardLeave :: HasCallStack => FilePath -> IO () +testGroupMsgForwardLeave :: HasCallStack => TestParams -> IO () testGroupMsgForwardLeave = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4872,7 +4815,7 @@ testGroupMsgForwardLeave = alice <## "#team: bob left the group" cath <## "#team: bob left the group" -testGroupHistory :: HasCallStack => FilePath -> IO () +testGroupHistory :: HasCallStack => TestParams -> IO () testGroupHistory = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4916,7 +4859,7 @@ testGroupHistory = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryGroupLink :: HasCallStack => FilePath -> IO () +testGroupHistoryGroupLink :: HasCallStack => TestParams -> IO () testGroupHistoryGroupLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4964,7 +4907,7 @@ testGroupHistoryGroupLink = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryPreferenceOff :: HasCallStack => FilePath -> IO () +testGroupHistoryPreferenceOff :: HasCallStack => TestParams -> IO () testGroupHistoryPreferenceOff = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -5049,7 +4992,7 @@ testGroupHistoryPreferenceOff = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testGroupHistoryHostFile :: HasCallStack => FilePath -> IO () +testGroupHistoryHostFile :: HasCallStack => TestParams -> IO () testGroupHistoryHostFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5088,7 +5031,7 @@ testGroupHistoryHostFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupHistoryMemberFile :: HasCallStack => FilePath -> IO () +testGroupHistoryMemberFile :: HasCallStack => TestParams -> IO () testGroupHistoryMemberFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5127,7 +5070,7 @@ testGroupHistoryMemberFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupHistoryLargeFile :: HasCallStack => FilePath -> IO () +testGroupHistoryLargeFile :: HasCallStack => TestParams -> IO () testGroupHistoryLargeFile = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5185,7 +5128,7 @@ testGroupHistoryLargeFile = where cfg = testCfg {xftpDescrPartSize = 200} -testGroupHistoryMultipleFiles :: HasCallStack => FilePath -> IO () +testGroupHistoryMultipleFiles :: HasCallStack => TestParams -> IO () testGroupHistoryMultipleFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5265,7 +5208,7 @@ testGroupHistoryMultipleFiles = ((0, "hey bob"), Just "./tests/tmp/testfile_alice_1") ] -testGroupHistoryFileCancel :: HasCallStack => FilePath -> IO () +testGroupHistoryFileCancel :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancel = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5320,7 +5263,7 @@ testGroupHistoryFileCancel = bob <## "#team: new member cath is connected" ] -testGroupHistoryFileCancelNoText :: HasCallStack => FilePath -> IO () +testGroupHistoryFileCancelNoText :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancelNoText = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5379,7 +5322,7 @@ testGroupHistoryFileCancelNoText = bob <## "#team: new member cath is connected" ] -testGroupHistoryQuotes :: HasCallStack => FilePath -> IO () +testGroupHistoryQuotes :: HasCallStack => TestParams -> IO () testGroupHistoryQuotes = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5408,7 +5351,7 @@ testGroupHistoryQuotes = alice `send` "> #team @bob (BOB) 2" alice <# "#team > bob BOB" alice <## " 2" - bob <# "#team alice> > bob BOB" + bob <# "#team alice!> > bob BOB" bob <## " 2" threadDelay 1000000 @@ -5416,7 +5359,7 @@ testGroupHistoryQuotes = bob `send` "> #team @alice (ALICE) 3" bob <# "#team > alice ALICE" bob <## " 3" - alice <# "#team bob> > alice ALICE" + alice <# "#team bob!> > alice ALICE" alice <## " 3" threadDelay 1000000 @@ -5485,11 +5428,12 @@ testGroupHistoryQuotes = ((0, "4"), Just (0, "BOB")) ] -testGroupHistoryDeletedMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryDeletedMessage :: HasCallStack => TestParams -> IO () testGroupHistoryDeletedMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup2 "team" alice bob + -- disableFullDeletion2 "team" alice bob alice #> "#team hello" bob <# "#team alice> hello" @@ -5523,7 +5467,7 @@ testGroupHistoryDeletedMessage = r `shouldContain` [(0, "hello")] r `shouldNotContain` [(0, "hey!")] -testGroupHistoryDisappearingMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryDisappearingMessage :: HasCallStack => TestParams -> IO () testGroupHistoryDisappearingMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5607,7 +5551,7 @@ testGroupHistoryDisappearingMessage = r2 `shouldNotContain` [(0, "2")] r2 `shouldNotContain` [(0, "3")] -testGroupHistoryWelcomeMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryWelcomeMessage :: HasCallStack => TestParams -> IO () testGroupHistoryWelcomeMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5664,7 +5608,7 @@ testGroupHistoryWelcomeMessage = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryUnknownMember :: HasCallStack => FilePath -> IO () +testGroupHistoryUnknownMember :: HasCallStack => TestParams -> IO () testGroupHistoryUnknownMember = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -5732,7 +5676,7 @@ testGroupHistoryUnknownMember = dan #> "#team 3" [alice, cath] *<# "#team dan> 3" -testMembershipProfileUpdateNextGroupMessage :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNextGroupMessage :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateNextGroupMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5830,12 +5774,12 @@ testMembershipProfileUpdateNextGroupMessage = rc <- chat <$> getTermLine cath rc `shouldContain` [(0, "updated profile")] -testMembershipProfileUpdateSameMember :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateSameMember :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateSameMember = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob - createGroup2' "club" alice bob False + createGroup2' "club" alice (bob, GRAdmin) False alice ##> "/d bob" alice <## "bob: contact is deleted" @@ -5888,7 +5832,7 @@ testMembershipProfileUpdateSameMember = rClub <- chat <$> getTermLine bob rClub `shouldNotContain` [(0, "updated profile")] -testMembershipProfileUpdateContactActive :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactActive :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactActive = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -5959,7 +5903,7 @@ testMembershipProfileUpdateContactActive = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testMembershipProfileUpdateContactDeleted :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDeleted :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactDeleted = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6038,7 +5982,7 @@ testMembershipProfileUpdateContactDeleted = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testMembershipProfileUpdateContactDisabled :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDisabled :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactDisabled = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6093,7 +6037,7 @@ testMembershipProfileUpdateContactDisabled = rGrp <- chat <$> getTermLine bob rGrp `shouldContain` [(0, "updated profile")] -testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateNoChangeIgnored = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6132,7 +6076,7 @@ testMembershipProfileUpdateNoChangeIgnored = rGrp <- chat <$> getTermLine bob rGrp `shouldNotContain` [(0, "updated profile")] -testMembershipProfileUpdateContactLinkIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactLinkIgnored :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactLinkIgnored = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6174,11 +6118,12 @@ testMembershipProfileUpdateContactLinkIgnored = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testBlockForAllMarkedBlocked :: HasCallStack => FilePath -> IO () +testBlockForAllMarkedBlocked :: HasCallStack => TestParams -> IO () testBlockForAllMarkedBlocked = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 @@ -6261,11 +6206,12 @@ testBlockForAllMarkedBlocked = ) bob #$> ("/_get chat #1 count=4", chat, [(1, "1"), (1, "2"), (1, "3"), (1, "4")]) -testBlockForAllFullDelete :: HasCallStack => FilePath -> IO () +testBlockForAllFullDelete :: HasCallStack => TestParams -> IO () testBlockForAllFullDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath alice ##> "/set delete #team on" alice <## "updated group preferences:" @@ -6341,11 +6287,12 @@ testBlockForAllFullDelete = ) bob #$> ("/_get chat #1 count=4", chat, [(1, "1"), (1, "2"), (1, "3"), (1, "4")]) -testBlockForAllAnotherAdminUnblocks :: HasCallStack => FilePath -> IO () +testBlockForAllAnotherAdminUnblocks :: HasCallStack => TestParams -> IO () testBlockForAllAnotherAdminUnblocks = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -6369,11 +6316,12 @@ testBlockForAllAnotherAdminUnblocks = bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) -testBlockForAllBeforeJoining :: HasCallStack => FilePath -> IO () +testBlockForAllBeforeJoining :: HasCallStack => TestParams -> IO () testBlockForAllBeforeJoining = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -6437,17 +6385,12 @@ testBlockForAllBeforeJoining = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testBlockForAllCantRepeat :: HasCallStack => FilePath -> IO () -testBlockForAllCantRepeat = +testBlockForAllRepeat :: HasCallStack => TestParams -> IO () +testBlockForAllRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - - alice ##> "/unblock for all #team bob" - alice <## "bad chat command: already unblocked" - - cath ##> "/unblock for all #team bob" - cath <## "bad chat command: already unblocked" + -- disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -6458,10 +6401,10 @@ testBlockForAllCantRepeat = bob "/block for all #team bob" - alice <## "bad chat command: already blocked" + alice <## "#team: you blocked bob" cath ##> "/block for all #team bob" - cath <## "bad chat command: already blocked" + cath <## "#team: you blocked bob" bob #> "#team 2" alice <# "#team bob> 2 [blocked by admin] " @@ -6473,21 +6416,97 @@ testBlockForAllCantRepeat = bob "/unblock for all #team bob" - alice <## "bad chat command: already unblocked" + alice <## "#team: you unblocked bob" cath ##> "/unblock for all #team bob" - cath <## "bad chat command: already unblocked" + cath <## "#team: you unblocked bob" bob #> "#team 3" [alice, cath] *<# "#team bob> 3" bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) -testGroupMemberInactive :: HasCallStack => FilePath -> IO () -testGroupMemberInactive tmp = do +testBlockForAllMultipleMembers :: HasCallStack => TestParams -> IO () +testBlockForAllMultipleMembers = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "team" alice bob cath + + connectUsers alice dan + addMember "team" alice dan GRMember + dan ##> "/j team" + concurrentlyN_ + [ alice <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ], + 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" + ] + + -- lower roles to for batch block to be allowed (can't batch block if admins are selected) + alice ##> "/mr team bob member" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob to member", + bob <## "#team: alice changed your role from admin to member", + cath <## "#team: alice changed the role of bob from admin to member", + dan <## "#team: alice changed the role of bob from admin to member" + ] + alice ##> "/mr team cath member" + concurrentlyN_ + [ alice <## "#team: you changed the role of cath to member", + bob <## "#team: alice changed the role of cath from admin to member", + cath <## "#team: alice changed your role from admin to member", + dan <## "#team: alice changed the role of cath from admin to member" + ] + + bob #> "#team 1" + [alice, cath, dan] *<# "#team bob> 1" + + cath #> "#team 2" + [alice, bob, dan] *<# "#team cath> 2" + + alice ##> "/_block #1 2,3 blocked=on" + alice <## "#team: you blocked 2 members" + dan <## "#team: alice blocked bob" + dan <## "#team: alice blocked cath" + bob "#team 3" + [alice, dan] *<# "#team bob> 3 [blocked by admin] " + cath <# "#team bob> 3" + + cath #> "#team 4" + [alice, dan] *<# "#team cath> 4 [blocked by admin] " + bob <# "#team cath> 4" + + alice ##> "/_block #1 2,3 blocked=off" + alice <## "#team: you unblocked 2 members" + dan <## "#team: alice unblocked bob" + dan <## "#team: alice unblocked cath" + bob "#team 5" + [alice, cath, dan] *<# "#team bob> 5" + + cath #> "#team 6" + [alice, bob, dan] *<# "#team cath> 6" + +testGroupMemberInactive :: HasCallStack => TestParams -> IO () +testGroupMemberInactive ps = do withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp cfg' opts' "alice" aliceProfile $ \alice -> do - withNewTestChatCfgOpts tmp cfg' opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" @@ -6510,7 +6529,7 @@ testGroupMemberInactive tmp = do threadDelay 1500000 - withTestChatCfgOpts tmp cfg' opts' "bob" $ \bob -> do + withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <# "#team alice> 1" @@ -6553,20 +6572,21 @@ testGroupMemberInactive tmp = do } } -testGroupMemberReports :: HasCallStack => FilePath -> IO () +testGroupMemberReports :: HasCallStack => TestParams -> IO () testGroupMemberReports = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "jokes" alice bob cath + -- disableFullDeletion3 "jokes" alice bob cath alice ##> "/mr jokes bob moderator" concurrentlyN_ - [ alice <## "#jokes: you changed the role of bob from admin to moderator", + [ alice <## "#jokes: you changed the role of bob to moderator", bob <## "#jokes: alice changed your role from admin to moderator", cath <## "#jokes: alice changed the role of bob from admin to moderator" ] alice ##> "/mr jokes cath member" concurrentlyN_ - [ alice <## "#jokes: you changed the role of cath from admin to member", + [ alice <## "#jokes: you changed the role of cath to member", bob <## "#jokes: alice changed the role of cath from admin to member", cath <## "#jokes: alice changed your role from admin to member" ] @@ -6599,14 +6619,14 @@ testGroupMemberReports = dan <# "#jokes cath> inappropriate joke" ] dan ##> "/report #jokes content inappropriate joke" - dan <# "#jokes > cath inappropriate joke" + dan <# "#jokes (support) > cath inappropriate joke" dan <## " report content" concurrentlyN_ [ do - alice <# "#jokes dan> > cath inappropriate joke" + alice <# "#jokes (support: dan) dan> > cath inappropriate joke" alice <## " report content", do - bob <# "#jokes dan> > cath inappropriate joke" + bob <# "#jokes (support: dan) dan> > cath inappropriate joke" bob <## " report content", (cath "\\\\ #jokes cath inappropriate joke" concurrentlyN_ [ do - alice <## "#jokes: 1 messages deleted by member alice" + alice <## "#jokes: 1 messages deleted by user" alice <## "message marked deleted by you", do bob <# "#jokes cath> [marked deleted by alice] inappropriate joke" @@ -6629,3 +6649,534 @@ testGroupMemberReports = alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by alice]")]) dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content [marked deleted by alice]")]) + -- delete all reports locally + alice #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + bob #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + dan #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + cath #> "#jokes ok joke" + concurrentlyN_ + [ alice <# "#jokes cath> ok joke", + bob <# "#jokes cath> ok joke", + dan <# "#jokes cath> ok joke" + ] + dan ##> "/report #jokes content ok joke" + dan <# "#jokes (support) > cath ok joke" + dan <## " report content" + dan ##> "/report #jokes spam ok joke" + dan <# "#jokes (support) > cath ok joke" + dan <## " report spam" + concurrentlyN_ + [ do + alice <# "#jokes (support: dan) dan> > cath ok joke" + alice <## " report content" + alice <# "#jokes (support: dan) dan> > cath ok joke" + alice <## " report spam", + do + bob <# "#jokes (support: dan) dan> > cath ok joke" + bob <## " report content" + bob <# "#jokes (support: dan) dan> > cath ok joke" + bob <## " report spam", + (cath ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) + cath #$> ("/_get chat #1 content=report count=100", chat, []) + dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content"), (1, "report spam")]) + alice ##> "/_archive reports #1" + alice <## "#jokes: 2 messages deleted by user" + (bob ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) + bob ##> "/_archive reports #1" + bob <## "#jokes: 2 messages deleted by user" + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) + -- delete reports for all admins + alice #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + bob #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + dan #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + cath #> "#jokes ok joke 2" + concurrentlyN_ + [ alice <# "#jokes cath> ok joke 2", + bob <# "#jokes cath> ok joke 2", + dan <# "#jokes cath> ok joke 2" + ] + dan ##> "/report #jokes content ok joke 2" + dan <# "#jokes (support) > cath ok joke 2" + dan <## " report content" + concurrentlyN_ + [ do + alice <# "#jokes (support: dan) dan> > cath ok joke 2" + alice <## " report content", + do + bob <# "#jokes (support: dan) dan> > cath ok joke 2" + bob <## " report content", + (cath "/last_item_id" + i :: ChatItemId <- read <$> getTermLine alice + alice ##> ("/_delete reports #1 " <> show i <> " broadcast") + alice <## "message marked deleted by you" + bob <# "#jokes (support: dan) dan> [marked deleted by alice] report content" + alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by alice]")]) + dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content")]) + +testMemberMention :: HasCallStack => TestParams -> IO () +testMemberMention = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice #> "#team hello!" + concurrentlyN_ + [ bob <# "#team alice> hello!", + cath <# "#team alice> hello!" + ] + bob #> "#team hello @alice" + concurrentlyN_ + [ alice <# "#team bob!> hello @alice", + cath <# "#team bob> hello @alice" + ] + alice #> "#team hello @bob @bob @cath" + concurrentlyN_ + [ bob <# "#team alice!> hello @bob @bob @cath", + cath <# "#team alice!> hello @bob @bob @cath" + ] + cath #> "#team hello @Alice" -- not a mention + concurrentlyN_ + [ alice <# "#team cath> hello @Alice", + bob <# "#team cath> hello @Alice" + ] + cath ##> "! #team hello @alice" -- make it a mention + cath <# "#team [edited] hello @alice" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello @alice", + bob <# "#team cath> [edited] hello @alice" + ] + cath ##> "! #team hello @alice @bob" -- add a mention + cath <# "#team [edited] hello @alice @bob" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello @alice @bob", + bob <# "#team cath> [edited] hello @alice @bob" + ] + +testForwardQuoteMention :: HasCallStack => TestParams -> IO () +testForwardQuoteMention = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob #> "#team hello @alice @cath" + concurrentlyN_ + [ alice <# "#team bob!> hello @alice @cath", + cath <# "#team bob!> hello @alice @cath" + ] + -- quote mentions + alice `send` "> #team @bob (hello) hi there!" + alice <# "#team > bob hello @alice @cath" + alice <## " hi there!" + concurrently_ + ( do + bob <# "#team alice!> > bob hello @alice @cath" + bob <## " hi there!" + ) + ( do + cath <# "#team alice> > bob hello @alice @cath" + cath <## " hi there!" + ) + -- forward mentions to the same group + alice `send` "#team <- #team hello" + alice <# "#team <- #team" + alice <## " hello @alice @cath" + concurrentlyN_ + [ do + bob <# "#team alice> -> forwarded" + bob <## " hello @alice @cath", + do + cath <# "#team alice!> -> forwarded" + cath <## " hello @alice @cath" + ] + -- forward mentions + alice `send` "@bob <- #team hello" + alice <# "@bob <- #team" + alice <## " hello @alice @cath" + bob <# "alice> -> forwarded" + bob <## " hello @alice @cath" + -- member renamed to duplicate name + cath ##> "/p alice_1" + cath <## "user profile is changed to alice_1 (your 1 contacts are notified)" + alice <## "contact cath changed to alice_1" + alice <## "use @alice_1 to send messages" + -- mention changed in quoted mentions + alice `send` "> #team @bob (hello) hi there!" + alice <# "#team > bob hello @alice @alice_1" + alice <## " hi there!" + concurrently_ + ( do + bob <# "#team alice!> > bob hello @alice @alice_1" + bob <## " hi there!" + ) + ( do + cath <# "#team alice> > bob hello @alice @alice_1" + cath <## " hi there!" + ) + -- mention changed in forwarded message + alice `send` "@bob <- #team hello" + alice <# "@bob <- #team" + alice <## " hello @alice @alice_1" + bob <# "alice> -> forwarded" + bob <## " hello @alice @alice_1" + +testGroupHistoryWithMentions :: HasCallStack => TestParams -> IO () +testGroupHistoryWithMentions = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + + threadDelay 1000000 + + alice #> "#team hello @bob" + bob <# "#team alice!> hello @bob" + + bob ##> "/p robert" + bob <## "user profile is changed to robert (your 1 contacts are notified)" + alice <## "contact bob changed to robert" + alice <## "use @robert to send messages" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath joined the group", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + WithTime "#team alice> hello @robert [>>]", + "#team: member robert is connected" + ], + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + +testUniqueMsgMentions :: SpecWith TestParams +testUniqueMsgMentions = do + it "1 correct mention" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd")]) ["alice"] + `shouldBe` (mm [("alice", "abcd")]) + it "2 correct mentions" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("bob", "efgh")]) ["alice", "bob"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + it "2 correct mentions with repetition" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("bob", "efgh")]) ["alice", "alice", "alice", "bob", "bob", "bob"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + it "too many mentions - drop extras" $ \_ -> + uniqueMsgMentions 3 (mm [("a", "abcd"), ("b", "efgh"), ("c", "1234"), ("d", "5678")]) ["a", "a", "a", "b", "b", "c", "d"] + `shouldBe` (mm [("a", "abcd"), ("b", "efgh"), ("c", "1234")]) + it "repeated-with-different name - drop extras" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("alice2", "abcd"), ("bob", "efgh"), ("bob2", "efgh")]) ["alice", "alice2", "bob", "bob2"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + where + mm = M.fromList . map (second $ MsgMention . MemberId) + +testUpdatedMentionNames :: SpecWith TestParams +testUpdatedMentionNames = do + it "keep mentions" $ \_ -> do + test (mm [("alice", Just "alice"), ("bob", Nothing)]) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + test (mm [("alice_1", Just "alice"), ("alice", Just "alice")]) "hello @alice @alice_1" + `shouldBe` "hello @alice @alice_1" + it "keep non-mentions" $ \_ -> do + test (mm []) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + test (mm [("alice", Just "alice")]) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + it "replace changed names" $ \_ -> do + test (mm [("alice", Just "Alice Jones"), ("bob", Just "robert")]) "hello @alice @bob" + `shouldBe` "hello @'Alice Jones' @robert" + test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" + `shouldBe` "hello @alice @alice_1" + where + test mentionsMap t = + let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentionsMap + in msgContentText mc' + mm = M.fromList . map (second mentionedMember) + mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = ciMentionMember <$> name_} + where + ciMentionMember name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} + +testScopedSupportSingleModerator :: HasCallStack => TestParams -> IO () +testScopedSupportSingleModerator = + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) + + alice #> "#team 1" + [bob, cath] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath] *<# "#team bob> 2" + + alice ##> "/_send #1(_support:2) text 3" + alice <# "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + + bob ##> "/_send #1(_support) text 4" + bob <# "#team (support) 4" + alice <# "#team (support: bob) bob> 4" + + cath ##> "/_send #1(_support:3) text 5" + cath <## "#team: you have insufficient permissions for this action, the required role is moderator" + + alice ##> "/_delete member chat #1 2" + alice <## "#team: bob support chat deleted" + +testScopedSupportManyModerators :: HasCallStack => TestParams -> IO () +testScopedSupportManyModerators = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) + + threadDelay 1000000 + + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + threadDelay 1000000 + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + threadDelay 1000000 + + alice ##> "/_send #1(_support:2) text 3" + alice <# "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + dan <# "#team (support: bob) alice> 3" + + threadDelay 1000000 + + bob ##> "/_send #1(_support) text 4" + bob <# "#team (support) 4" + [alice, dan] *<# "#team (support: bob) bob> 4" + + threadDelay 1000000 + + dan ##> "/_send #1(_support:3) text 5" + dan <# "#team (support: bob) 5" + alice <# "#team (support: bob) dan> 5" + bob <# "#team (support) dan> 5" + + alice @@@ [("#team","2"), ("@dan","sent invitation to join group team as moderator"), ("@cath","sent invitation to join group team as member"), ("@bob","sent invitation to join group team as member")] + bob @@@ [("#team","2"), ("@alice","received invitation to join group team as member")] + dan @@@ [("#team","2"), ("@alice","received invitation to join group team as moderator")] + cath @@@ [("#team","2"), ("@alice","received invitation to join group team as member")] + + alice #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (1, "1"), (0, "2")]) + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(1, "3"), (0, "4"), (0, "5")]) + bob #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (1, "2")]) + bob #$> ("/_get chat #1(_support) count=100", chat, [(0, "3"), (1, "4"), (0, "5")]) + dan #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (0, "2")]) + dan #$> ("/_get chat #1(_support:3) count=100", chat, [(0, "3"), (0, "4"), (1, "5")]) + cath #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (0, "2")]) + cath #$> ("/_get chat #1(_support:3) count=100", chat, []) + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 0, require attention: 0, mentions: 0" + cath ##> "/member support chats #team" + cath <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" + +testSupportCLISendCommand :: HasCallStack => TestParams -> IO () +testSupportCLISendCommand = + testChat2 aliceProfile bobProfile $ \alice bob -> do + createGroup2' "team" alice (bob, GRObserver) True + + alice #> "#team 1" + bob <# "#team alice> 1" + + bob ##> "#team 2" + bob <## "#team: you don't have permission to send messages" + (alice "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + + bob #> "#team (support) 4" + alice <# "#team (support: bob) bob> 4" + + bob ##> "#team (support 4" + bob <## "bad chat command: Failed reading: empty" + +testScopedSupportUnreadStatsOnRead :: HasCallStack => TestParams -> IO () +testScopedSupportUnreadStatsOnRead = + testChatOpts4 opts aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) + + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + alice ##> "/_send #1(_support:2) text 3" + alice <# "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + dan <# "#team (support: bob) alice> 3" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 1, require attention: 0, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 1, require attention: 0, mentions: 0" + + bob ##> "/_send #1(_support) text 4" + bob <# "#team (support) 4" + [alice, dan] *<# "#team (support: bob) bob> 4" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 1, require attention: 0, mentions: 0" + + threadDelay 1000000 + + dan ##> "/_send #1(_support:3) text 5" + dan <# "#team (support: bob) 5" + alice <# "#team (support: bob) dan> 5" + bob <# "#team (support) dan> 5" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 0, mentions: 0" + -- In test "answering" doesn't reset unanswered, but in UI items would be marked read on opening chat + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 2, require attention: 0, mentions: 0" + + threadDelay 1000000 + + dan ##> "/_send #1(_support:3) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@alice 6\"}, \"mentions\": {\"alice\": 1}}]" + dan <# "#team (support: bob) @alice 6" + alice <# "#team (support: bob) dan!> @alice 6" + bob <# "#team (support) dan> @alice 6" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 0, mentions: 1" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 3, require attention: 0, mentions: 0" + + aliceMentionedByDanItemId <- lastItemId alice + + threadDelay 1000000 + + bob ##> "/_send #1(_support) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@alice 7\"}, \"mentions\": {\"alice\": 1}}]" + bob <# "#team (support) @alice 7" + alice <# "#team (support: bob) bob!> @alice 7" + dan <# "#team (support: bob) bob> @alice 7" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 1, mentions: 2" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 3, require attention: 2, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 3, require attention: 0, mentions: 0" + + aliceMentionedByBobItemId <- lastItemId alice + + bob ##> "/_send #1(_support) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@dan 8\"}, \"mentions\": {\"dan\": 4}}]" + bob <# "#team (support) @dan 8" + alice <# "#team (support: bob) bob> @dan 8" + dan <# "#team (support: bob) bob!> @dan 8" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 5, require attention: 2, mentions: 2" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" + bob ##> "/member support chats #team" + bob <## "support: unread: 3, require attention: 0, mentions: 0" + + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "items read for chat") + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1" + + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "items read for chat") + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" + + threadDelay 1000000 + + dan ##> "/_send #1(_support:3) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@bob 9\"}, \"mentions\": {\"bob\": 3}}]" + dan <# "#team (support: bob) @bob 9" + alice <# "#team (support: bob) dan> @bob 9" + bob <# "#team (support) dan!> @bob 9" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 0, mentions: 0" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" + bob ##> "/member support chats #team" + bob <## "support: unread: 4, require attention: 0, mentions: 1" + + alice #$> ("/_read chat #1(_support:2)", id, "ok") + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + dan #$> ("/_read chat #1(_support:3)", id, "ok") + + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" + + bob #$> ("/_read chat #1(_support)", id, "ok") + + bob ##> "/member support chats #team" + bob <## "support: unread: 0, require attention: 0, mentions: 0" + + cath ##> "/member support chats #team" + cath TestParams -> IO () +testScopedSupportUnreadStatsOnDelete = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2 "team" alice bob + + alice ##> "/set delete #team on" + alice <## "updated group preferences:" + alice <## "Full deletion: on" + bob <## "alice updated group #team:" + bob <## "updated group preferences:" + bob <## "Full deletion: on" + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + msgIdBob <- lastItemId bob + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" + + bob #$> ("/_delete item #1(_support) " <> msgIdBob <> " broadcast", id, "message deleted") + alice <# "#team (support: bob) bob> [deleted] 1" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + where + opts = + testOpts + { markRead = False + } diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index 594e6e47b0..a5fb31ad77 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -5,6 +5,7 @@ module ChatTests.Local where import ChatClient import ChatTests.ChatList (getChats_) +import ChatTests.DBUtils import ChatTests.Utils import Data.Time (getCurrentTime) import Data.Time.Format.ISO8601 (iso8601Show) @@ -13,7 +14,7 @@ import System.Directory (copyFile, doesFileExist) import System.FilePath (()) import Test.Hspec hiding (it) -chatLocalChatsTests :: SpecWith FilePath +chatLocalChatsTests :: SpecWith TestParams chatLocalChatsTests = do describe "note folders" $ do it "create folders, add notes, read, search" testNotes @@ -26,8 +27,8 @@ chatLocalChatsTests = do it "create multiple messages api" testCreateMulti it "create multiple messages with files" testCreateMultiFiles -testNotes :: FilePath -> IO () -testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testNotes :: TestParams -> IO () +testNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice ##> "/contacts" @@ -55,8 +56,8 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail *" alice <# "* Greetings." -testUserNotes :: FilePath -> IO () -testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUserNotes :: TestParams -> IO () +testUserNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "keep in mind" @@ -73,8 +74,8 @@ testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/_delete item *1 1 internal" alice <## "chat db error: SENoteFolderNotFound {noteFolderId = 1}" -testPreviewsPagination :: FilePath -> IO () -testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPreviewsPagination :: TestParams -> IO () +testPreviewsPagination ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice tsS <- iso8601Show <$> getCurrentTime @@ -91,8 +92,8 @@ testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice - getChats_ alice ("before=" <> tsE <> " count=10") [("*", "last")] getChats_ alice ("before=" <> tsS <> " count=10") [] -testChatPagination :: FilePath -> IO () -testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testChatPagination :: TestParams -> IO () +testChatPagination ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "hello world" @@ -115,8 +116,8 @@ testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice #$> ("/_get chat *1 count=10 search=k-k", chat, [(1, "knock-knock")]) -testFiles :: FilePath -> IO () -testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testFiles :: TestParams -> IO () +testFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- setup createCCNoteFolder alice let files = "./tests/tmp/app_files" @@ -163,7 +164,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail" doesFileExist stored `shouldReturn` False -testOtherFiles :: FilePath -> IO () +testOtherFiles :: TestParams -> IO () testOtherFiles = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -196,16 +197,16 @@ testOtherFiles = where cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} -testCreateMulti :: FilePath -> IO () -testCreateMulti tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testCreateMulti :: TestParams -> IO () +testCreateMulti ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice ##> "/_create *1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}]" alice <# "* test 1" alice <# "* test 2" -testCreateMultiFiles :: FilePath -> IO () -testCreateMultiFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testCreateMultiFiles :: TestParams -> IO () +testCreateMultiFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 36fe576dcb..fe933d5b98 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -8,6 +8,7 @@ module ChatTests.Profiles where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -32,7 +33,7 @@ import Simplex.Messaging.Util (encodeJSON) import System.Directory (copyFile, createDirectoryIfMissing) import Test.Hspec hiding (it) -chatProfileTests :: SpecWith FilePath +chatProfileTests :: SpecWith TestParams chatProfileTests = do describe "user profiles" $ do it "update user profile and notify contacts" testUpdateProfile @@ -100,12 +101,19 @@ 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 => FilePath -> IO () +testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + connectUsers alice bob + connectUsers alice cath + connectUsers bob cath alice ##> "/p" alice <## "user profile: alice (Alice)" alice <## "use /p to change it" @@ -143,7 +151,7 @@ testUpdateProfile = bob <## "use @cat to send messages" ] -testUpdateProfileImage :: HasCallStack => FilePath -> IO () +testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -163,7 +171,7 @@ testUpdateProfileImage = bob <## "use @alice2 to send messages" (bob FilePath -> IO () +testMultiWordProfileNames :: HasCallStack => TestParams -> IO () testMultiWordProfileNames = testChat3 aliceProfile' bobProfile' cathProfile' $ \alice bob cath -> do @@ -236,7 +244,7 @@ testMultiWordProfileNames = cathProfile' = baseProfile {displayName = "Cath Johnson"} baseProfile = Profile {displayName = "", fullName = "", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} -testUserContactLink :: HasCallStack => FilePath -> IO () +testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -266,9 +274,10 @@ testUserContactLink = alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testRetryAcceptingViaContactLink :: HasCallStack => FilePath -> IO () -testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test tmp +testRetryAcceptingViaContactLink :: HasCallStack => TestParams -> IO () +testRetryAcceptingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps where + tmp = tmpPath ps test alice bob = do cLink <- withSmpServer' serverCfg' $ do alice ##> "/ad" @@ -307,8 +316,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile smpServerCfg { transports = [("7003", transport @TLS, False)], msgQueueQuota = 2, - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests cfg' = @@ -327,7 +335,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile } } -testProfileLink :: HasCallStack => FilePath -> IO () +testProfileLink :: HasCallStack => TestParams -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -408,7 +416,7 @@ testProfileLink = cc <## "quantum resistant end-to-end encryption" cc <## currentChatVRangeInfo -testUserContactLinkAutoAccept :: HasCallStack => FilePath -> IO () +testUserContactLinkAutoAccept :: HasCallStack => TestParams -> IO () testUserContactLinkAutoAccept = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -456,7 +464,7 @@ testUserContactLinkAutoAccept = alice @@@ [("@dan", lastChatFeature), ("@cath", "hey"), ("@bob", "hey")] alice <##> dan -testDeduplicateContactRequests :: HasCallStack => FilePath -> IO () +testDeduplicateContactRequests :: HasCallStack => TestParams -> IO () testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -515,7 +523,7 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testDeduplicateContactRequestsProfileChange :: HasCallStack => FilePath -> IO () +testDeduplicateContactRequestsProfileChange :: HasCallStack => TestParams -> IO () testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -592,7 +600,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile alice @@@ [("@cath", lastChatFeature), ("@robert", "hey")] alice <##> cath -testRejectContactAndDeleteUserContact :: HasCallStack => FilePath -> IO () +testRejectContactAndDeleteUserContact :: HasCallStack => TestParams -> IO () testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/_address 1" @@ -615,7 +623,7 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr cath ##> ("/c " <> cLink) cath <## "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" -testDeleteConnectionRequests :: HasCallStack => FilePath -> IO () +testDeleteConnectionRequests :: HasCallStack => TestParams -> IO () testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -637,7 +645,7 @@ testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ cath ##> ("/c " <> cLink') alice <#? cath -testAutoReplyMessage :: HasCallStack => FilePath -> IO () +testAutoReplyMessage :: HasCallStack => TestParams -> IO () testAutoReplyMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -659,7 +667,7 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $ alice <## "bob (Bob): contact is connected" ] -testAutoReplyMessageInIncognito :: HasCallStack => FilePath -> IO () +testAutoReplyMessageInIncognito :: HasCallStack => TestParams -> IO () testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -684,7 +692,7 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ alice <## "use /i bob to print out this incognito profile again" ] -testBusinessAddress :: HasCallStack => FilePath -> IO () +testBusinessAddress :: HasCallStack => TestParams -> IO () testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice @ Biz"} bobProfile $ \biz alice bob -> do biz ##> "/ad" @@ -737,7 +745,7 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice (alice <# "#bob bob_1> hey there") (biz <# "#bob bob_1> hey there") -testBusinessUpdateProfiles :: HasCallStack => FilePath -> IO () +testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO () testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $ \biz alice bob cath -> do biz ##> "/ad" @@ -752,8 +760,11 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c alice <## "#biz: joining the group..." biz <# "#alice Welcome" -- auto reply biz <## "#alice: alice_1 joined the group" - alice <# "#biz biz_1> Welcome" - alice <## "#biz: you joined the group" + alice + <### + [ WithTime "#biz biz_1> Welcome", + "#biz: you joined the group" + ] biz #> "#alice hi" alice <# "#biz biz_1> hi" alice #> "#biz hello" @@ -766,7 +777,7 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c biz <# "#alisa alisa_1> hello again" -- customer can invite members too, if business allows biz ##> "/mr alisa alisa_1 admin" - biz <## "#alisa: you changed the role of alisa_1 from member to admin" + biz <## "#alisa: you changed the role of alisa_1 to admin" alice <## "#biz: biz_1 changed your role from member to admin" connectUsers alice bob alice ##> "/a #biz bob" @@ -866,7 +877,7 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c bob #$> ("/_get chat #1 count=1", chat, [(0, "Voice messages: on")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "Voice messages: on")]) -testPlanAddressOkKnown :: HasCallStack => FilePath -> IO () +testPlanAddressOkKnown :: HasCallStack => TestParams -> IO () testPlanAddressOkKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -899,9 +910,9 @@ testPlanAddressOkKnown = bob <## "contact address: known contact alice" bob <## "use @alice to send messages" -testPlanAddressOwn :: HasCallStack => FilePath -> IO () -testPlanAddressOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanAddressOwn :: HasCallStack => TestParams -> IO () +testPlanAddressOwn ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/ad" cLink <- getContactLink alice True @@ -945,12 +956,12 @@ testPlanAddressOwn tmp = alice ##> ("/c " <> cLink) alice <## "alice_2 (Alice): contact already exists" -testPlanAddressConnecting :: HasCallStack => FilePath -> IO () -testPlanAddressConnecting tmp = do - cLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanAddressConnecting :: HasCallStack => TestParams -> IO () +testPlanAddressConnecting ps = do + cLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/ad" getContactLink alice True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> cLink) @@ -964,14 +975,14 @@ testPlanAddressConnecting tmp = do bob <## "contact address: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do threadDelay 500000 bob <## "alice (Alice): contact is connected" bob @@@ [("@alice", "Audio/video calls: enabled")] @@ -988,12 +999,12 @@ testPlanAddressConnecting tmp = do bob <## "contact address: known contact alice" bob <## "use @alice to send messages" -testPlanAddressConnectingSlow :: HasCallStack => FilePath -> IO () -testPlanAddressConnectingSlow tmp = do - cLink <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testPlanAddressConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanAddressConnectingSlow ps = do + cLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do alice ##> "/ad" getContactLink alice True - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> cLink) @@ -1007,14 +1018,14 @@ testPlanAddressConnectingSlow tmp = do bob <## "contact address: connecting, allowed to reconnect" threadDelay 100000 - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1027,7 +1038,7 @@ testPlanAddressConnectingSlow tmp = do bob ##> ("/c " <> cLink) bob <## "contact address: connecting to contact alice" -testPlanAddressContactDeletedReconnected :: HasCallStack => FilePath -> IO () +testPlanAddressContactDeletedReconnected :: HasCallStack => TestParams -> IO () testPlanAddressContactDeletedReconnected = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1090,7 +1101,7 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" -testPlanAddressContactViaAddress :: HasCallStack => FilePath -> IO () +testPlanAddressContactViaAddress :: HasCallStack => TestParams -> IO () testPlanAddressContactViaAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1154,7 +1165,7 @@ testPlanAddressContactViaAddress = alice <##> bob bob @@@ [("@alice", "hey")] -testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () +testConnectIncognitoInvitationLink :: HasCallStack => TestParams -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/connect incognito" @@ -1228,7 +1239,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi (bob FilePath -> IO () +testConnectIncognitoContactAddress :: HasCallStack => TestParams -> IO () testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -1266,7 +1277,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ (bob FilePath -> IO () +testAcceptContactRequestIncognito :: HasCallStack => TestParams -> IO () testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -1314,7 +1325,7 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil alice `hasContactProfiles` ["alice", "cath", T.pack aliceIncognitoCath] cath `hasContactProfiles` ["cath", T.pack aliceIncognitoCath] -testSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetConnectionIncognito :: HasCallStack => TestParams -> IO () testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/connect" @@ -1337,7 +1348,7 @@ testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] bob `hasContactProfiles` ["bob", T.pack aliceIncognito] -testResetConnectionIncognito :: HasCallStack => FilePath -> IO () +testResetConnectionIncognito :: HasCallStack => TestParams -> IO () testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/_connect 1 incognito=on" @@ -1353,42 +1364,42 @@ testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => FilePath -> IO () -testSetConnectionIncognitoProhibitedDuringNegotiation tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => TestParams -> IO () +testSetConnectionIncognitoProhibitedDuringNegotiation ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/connect" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do threadDelay 250000 alice <## "bob (Bob): contact is connected" alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "alice (Alice): contact is connected" alice <##> bob alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetConnectionIncognitoProhibitedDuringNegotiationSlow :: HasCallStack => FilePath -> IO () -testSetConnectionIncognitoProhibitedDuringNegotiationSlow tmp = do - inv <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testSetConnectionIncognitoProhibitedDuringNegotiationSlow :: HasCallStack => TestParams -> IO () +testSetConnectionIncognitoProhibitedDuringNegotiationSlow ps = do + inv <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/connect" getInvitation alice - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do threadDelay 250000 alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1396,7 +1407,7 @@ testSetConnectionIncognitoProhibitedDuringNegotiationSlow tmp = do alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testConnectionIncognitoUnchangedErrors :: HasCallStack => FilePath -> IO () +testConnectionIncognitoUnchangedErrors :: HasCallStack => TestParams -> IO () testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/connect" @@ -1420,7 +1431,7 @@ testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetResetSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetResetSetConnectionIncognito :: HasCallStack => TestParams -> IO () testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/_connect 1 incognito=off" @@ -1447,9 +1458,9 @@ testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] bob `hasContactProfiles` ["bob", T.pack aliceIncognito] -testJoinGroupIncognito :: HasCallStack => FilePath -> IO () +testJoinGroupIncognito :: HasCallStack => TestParams -> IO () testJoinGroupIncognito = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- non incognito connections connectUsers alice bob @@ -1524,13 +1535,13 @@ testJoinGroupIncognito = dan <### [ ConsoleString $ "#secret_club: member " <> cathIncognito <> " is connected", "#secret_club: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", + "contact and member are merged: bob, #secret_club bob_1", "use @bob to send messages" ], do bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" bob <## "#secret_club: new member dan_1 is connected" - bob <## "contact dan_1 is merged into dan" + bob <## "contact and member are merged: dan, #secret_club dan_1" bob <## "use @dan to send messages", do cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" @@ -1561,17 +1572,7 @@ testJoinGroupIncognito = bob <# "#secret_club dan> how is it going?", cath ?<# "#secret_club dan_1> how is it going?" ] - -- cath and bob can send messages via new direct connection, cath is incognito - bob #> ("@" <> cathIncognito <> " hi, I'm bob") - cath ?<# "bob_1> hi, I'm bob" - cath ?#> "@bob_1 hey, I'm incognito" - bob <# (cathIncognito <> "> hey, I'm incognito") - -- cath and dan can send messages via new direct connection, cath is incognito - dan #> ("@" <> cathIncognito <> " hi, I'm dan") - cath ?<# "dan_1> hi, I'm dan" - cath ?#> "@dan_1 hey, I'm incognito" - dan <# (cathIncognito <> "> hey, I'm incognito") - -- non incognito connections are separate + -- non incognito direct connections are separate bob <##> cath dan <##> cath -- list groups @@ -1629,14 +1630,9 @@ testJoinGroupIncognito = (cath "#secret_club hello" - cath <## "you are no longer a member of the group" - -- cath can still message members directly - bob #> ("@" <> cathIncognito <> " I removed you from group") - cath ?<# "bob_1> I removed you from group" - cath ?#> "@bob_1 ok" - bob <# (cathIncognito <> "> ok") + cath <## "bad chat command: not current member" -testCantInviteContactIncognito :: HasCallStack => FilePath -> IO () +testCantInviteContactIncognito :: HasCallStack => TestParams -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- alice connected incognito to bob @@ -1660,7 +1656,7 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ -- bob doesn't receive invitation (bob FilePath -> IO () +testCantSeeGlobalPrefsUpdateIncognito :: HasCallStack => TestParams -> IO () testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/c i" @@ -1711,7 +1707,7 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr cath <## "alice updated preferences for you:" cath <## "Full deletion: off (you allow: default (no), contact allows: yes)" -testDeleteContactThenGroupDeletesIncognitoProfile :: HasCallStack => FilePath -> IO () +testDeleteContactThenGroupDeletesIncognitoProfile :: HasCallStack => TestParams -> IO () testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do -- bob connects incognito to alice @@ -1763,7 +1759,7 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr bob <## "#team: you deleted the group" bob `hasContactProfiles` ["bob"] -testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => FilePath -> IO () +testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => TestParams -> IO () testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do -- bob connects incognito to alice @@ -1815,7 +1811,7 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr (bob FilePath -> IO () +testSetAlias :: HasCallStack => TestParams -> IO () testSetAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1826,7 +1822,7 @@ testSetAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob)" -testChangePCCUser :: HasCallStack => FilePath -> IO () +testChangePCCUser :: HasCallStack => TestParams -> IO () testChangePCCUser = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite @@ -1856,7 +1852,7 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ (alice <## "bob (Bob): contact is connected") (bob <## "alisa2: contact is connected") -testChangePCCUserFromIncognito :: HasCallStack => FilePath -> IO () +testChangePCCUserFromIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito @@ -1887,7 +1883,7 @@ testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") -testChangePCCUserAndThenIncognito :: HasCallStack => FilePath -> IO () +testChangePCCUserAndThenIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito @@ -1916,11 +1912,11 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ alice <## ("use /i bob to print out this incognito profile again") ] -testChangePCCUserDiffSrv :: HasCallStack => FilePath -> IO () -testChangePCCUserDiffSrv tmp = do +testChangePCCUserDiffSrv :: HasCallStack => TestParams -> IO () +testChangePCCUserDiffSrv ps = do withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp testCfg testOpts "alice" aliceProfile $ \alice -> do - withNewTestChatCfgOpts tmp testCfg testOpts "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps testCfg testOpts "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps testCfg testOpts "bob" bobProfile $ \bob -> do -- Create a new invite alice ##> "/connect" _ <- getInvitation alice @@ -1962,7 +1958,7 @@ testChangePCCUserDiffSrv tmp = do msgQueueQuota = 2 } -testSetConnectionAlias :: HasCallStack => FilePath -> IO () +testSetConnectionAlias :: HasCallStack => TestParams -> IO () testSetConnectionAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/c" @@ -1980,7 +1976,7 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob) (alias: friend)" -testSetGroupAlias :: HasCallStack => FilePath -> IO () +testSetGroupAlias :: HasCallStack => TestParams -> IO () testSetGroupAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob @@ -1994,7 +1990,7 @@ testSetGroupAlias = testChat2 aliceProfile bobProfile $ alice ##> "/groups" alice <## "#team (2 members)" -testSetContactPrefs :: HasCallStack => FilePath -> IO () +testSetContactPrefs :: HasCallStack => TestParams -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") @@ -2087,7 +2083,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ bob <## "Voice messages: off (you allow: default (yes), contact allows: no)" bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off"), (1, "Voice messages: enabled"), (0, "Voice messages: off")]) -testFeatureOffers :: HasCallStack => FilePath -> IO () +testFeatureOffers :: HasCallStack => TestParams -> IO () testFeatureOffers = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -2106,7 +2102,7 @@ testFeatureOffers = testChat2 aliceProfile bobProfile $ bob <## "Full deletion: off (you allow: default (no), contact allows: no)" bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "offered Full deletion"), (0, "cancelled Full deletion")]) -testUpdateGroupPrefs :: HasCallStack => FilePath -> IO () +testUpdateGroupPrefs :: HasCallStack => TestParams -> IO () testUpdateGroupPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2157,7 +2153,7 @@ testUpdateGroupPrefs = alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on"), (0, "hey"), (1, "hi")]) -testAllowFullDeletionContact :: HasCallStack => FilePath -> IO () +testAllowFullDeletionContact :: HasCallStack => TestParams -> IO () testAllowFullDeletionContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2175,7 +2171,7 @@ testAllowFullDeletionContact = alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (1, "Full deletion: enabled for contact")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hi"), (0, "Full deletion: enabled for you")]) -testAllowFullDeletionGroup :: HasCallStack => FilePath -> IO () +testAllowFullDeletionGroup :: HasCallStack => TestParams -> IO () testAllowFullDeletionGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2201,9 +2197,9 @@ testAllowFullDeletionGroup = alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "hi"), (1, "Full deletion: on")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (0, "Full deletion: on")]) -testProhibitDirectMessages :: HasCallStack => FilePath -> IO () +testProhibitDirectMessages :: HasCallStack => TestParams -> IO () testProhibitDirectMessages = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -2219,7 +2215,7 @@ testProhibitDirectMessages = alice #> "@cath hello again" cath <# "alice> hello again" bob ##> "@cath hello again" - bob <## "direct messages to indirect contact cath are prohibited" + bob <## "bad chat command: direct messages not allowed" (cath "@dan hi" - alice <## "direct messages to indirect contact dan are prohibited" + alice <## "bad chat command: direct messages not allowed" bob ##> "@dan hi" - bob <## "direct messages to indirect contact dan are prohibited" + bob <## "bad chat command: direct messages not allowed" (dan "@alice hi" - dan <## "direct messages to indirect contact alice are prohibited" + dan <## "bad chat command: direct messages not allowed" dan ##> "@bob hi" - dan <## "direct messages to indirect contact bob are prohibited" + dan <## "bad chat command: direct messages not allowed" dan #> "@cath hi" cath <# "dan> hi" cath #> "@dan hi" @@ -2259,7 +2255,7 @@ testProhibitDirectMessages = cc <## "updated group preferences:" cc <## "Direct messages: off" -testEnableTimedMessagesContact :: HasCallStack => FilePath -> IO () +testEnableTimedMessagesContact :: HasCallStack => TestParams -> IO () testEnableTimedMessagesContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2304,7 +2300,7 @@ testEnableTimedMessagesContact = alice <## "bob updated preferences for you:" alice <## "Disappearing messages: enabled (you allow: yes (1 week), contact allows: yes (1 week))" -testEnableTimedMessagesGroup :: HasCallStack => FilePath -> IO () +testEnableTimedMessagesGroup :: HasCallStack => TestParams -> IO () testEnableTimedMessagesGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2354,7 +2350,7 @@ testEnableTimedMessagesGroup = bob <## "updated group preferences:" bob <## "Disappearing messages: on (1 week)" -testTimedMessagesEnabledGlobally :: HasCallStack => FilePath -> IO () +testTimedMessagesEnabledGlobally :: HasCallStack => TestParams -> IO () testTimedMessagesEnabledGlobally = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2378,7 +2374,7 @@ testTimedMessagesEnabledGlobally = alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)")]) -testUpdateMultipleUserPrefs :: HasCallStack => FilePath -> IO () +testUpdateMultipleUserPrefs :: HasCallStack => TestParams -> IO () testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -2405,7 +2401,7 @@ testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi bob"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "hi cath"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) -testGroupPrefsDirectForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsDirectForRole :: HasCallStack => TestParams -> IO () testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath @@ -2471,7 +2467,7 @@ testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danP cc <## "updated group preferences:" cc <## "Direct messages: on for owners" -testGroupPrefsFilesForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsFilesForRole :: HasCallStack => TestParams -> IO () testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") @@ -2506,7 +2502,7 @@ testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ cc <## "updated group preferences:" cc <## "Files and media: on for owners" -testGroupPrefsSimplexLinksForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsSimplexLinksForRole :: HasCallStack => TestParams -> IO () testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath @@ -2542,7 +2538,7 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil cc <## "updated group preferences:" cc <## "SimpleX links: on for owners" -testSetUITheme :: HasCallStack => FilePath -> IO () +testSetUITheme :: HasCallStack => TestParams -> IO () testSetUITheme = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -2592,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 24178125ee..38c8c308c8 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -9,8 +9,9 @@ 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) @@ -32,6 +33,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Client.Main (xftpClientCLI) +import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C @@ -69,16 +71,19 @@ cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Not danProfile :: Profile danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} +eveProfile :: Profile +eveProfile = Profile {displayName = "eve", fullName = "Eve", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} + businessProfile :: Profile businessProfile = Profile {displayName = "biz", fullName = "Biz Inc", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} -it :: HasCallStack => String -> (FilePath -> Expectation) -> SpecWith (Arg (FilePath -> Expectation)) +it :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) it name test = Hspec.it name $ \tmp -> timeout t (test tmp) >>= maybe (error "test timed out") pure where t = 90 * 1000000 -xit' :: HasCallStack => String -> (FilePath -> Expectation) -> SpecWith (Arg (FilePath -> Expectation)) +xit' :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) xit' = if os == "linux" then xit else it xit'' :: (HasCallStack, Example a) => String -> a -> SpecWith (Arg a) @@ -96,7 +101,7 @@ skip :: String -> SpecWith a -> SpecWith a skip = before_ . pendingWith -- Bool is pqExpected - see testAddContact -versionTestMatrix2 :: (HasCallStack => Bool -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +versionTestMatrix2 :: (HasCallStack => Bool -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile (runTest True) it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile (runTest False) @@ -106,7 +111,7 @@ versionTestMatrix2 runTest = do it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest False) it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest False) -versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix3 runTest = do it "current" $ testChat3 aliceProfile bobProfile cathProfile runTest it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest @@ -115,46 +120,46 @@ versionTestMatrix3 runTest = do it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest -runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () -runTestCfg2 aliceCfg bobCfg runTest tmp = - withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> +runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () +runTestCfg2 aliceCfg bobCfg runTest ps = + withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> runTest alice bob -runTestCfg3 :: ChatConfig -> ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () -runTestCfg3 aliceCfg bobCfg cathCfg runTest tmp = - withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp cathCfg "cath" cathProfile $ \cath -> +runTestCfg3 :: ChatConfig -> ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +runTestCfg3 aliceCfg bobCfg cathCfg runTest ps = + withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath -withTestChatGroup3Connected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatGroup3Connected tmp dbPrefix action = do - withTestChat tmp dbPrefix $ \cc -> do +withTestChatGroup3Connected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatGroup3Connected ps dbPrefix action = do + withTestChat ps dbPrefix $ \cc -> do cc <## "2 contacts connected (use /cs for the list)" cc <## "#team: connected to server(s)" action cc -withTestChatGroup3Connected' :: HasCallStack => FilePath -> String -> IO () -withTestChatGroup3Connected' tmp dbPrefix = withTestChatGroup3Connected tmp dbPrefix $ \_ -> pure () +withTestChatGroup3Connected' :: HasCallStack => TestParams -> String -> IO () +withTestChatGroup3Connected' ps dbPrefix = withTestChatGroup3Connected ps dbPrefix $ \_ -> pure () -withTestChatContactConnected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnected tmp dbPrefix action = - withTestChat tmp dbPrefix $ \cc -> do +withTestChatContactConnected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatContactConnected ps dbPrefix action = + withTestChat ps dbPrefix $ \cc -> do cc <## "1 contacts connected (use /cs for the list)" action cc -withTestChatContactConnected' :: HasCallStack => FilePath -> String -> IO () -withTestChatContactConnected' tmp dbPrefix = withTestChatContactConnected tmp dbPrefix $ \_ -> pure () +withTestChatContactConnected' :: HasCallStack => TestParams -> String -> IO () +withTestChatContactConnected' ps dbPrefix = withTestChatContactConnected ps dbPrefix $ \_ -> pure () -withTestChatContactConnectedV1 :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnectedV1 tmp dbPrefix action = - withTestChatV1 tmp dbPrefix $ \cc -> do +withTestChatContactConnectedV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatContactConnectedV1 ps dbPrefix action = + withTestChatV1 ps dbPrefix $ \cc -> do cc <## "1 contacts connected (use /cs for the list)" action cc -withTestChatContactConnectedV1' :: HasCallStack => FilePath -> String -> IO () -withTestChatContactConnectedV1' tmp dbPrefix = withTestChatContactConnectedV1 tmp dbPrefix $ \_ -> pure () +withTestChatContactConnectedV1' :: HasCallStack => TestParams -> String -> IO () +withTestChatContactConnectedV1' ps dbPrefix = withTestChatContactConnectedV1 ps dbPrefix $ \_ -> pure () -- | test sending direct messages (<##>) :: HasCallStack => TestCC -> TestCC -> IO () @@ -304,6 +309,7 @@ groupFeatures'' dir = ((dir, "Voice messages: on"), Nothing, Nothing), ((dir, "Files and media: on"), Nothing, Nothing), ((dir, "SimpleX links: on"), Nothing, Nothing), + ((dir, "Member reports: on"), Nothing, Nothing), ((dir, "Recent history: on"), Nothing, Nothing) ] @@ -360,6 +366,13 @@ cc <#. line = do unless prefix $ print ("expected to start from: " <> line, ", got: " <> l) prefix `shouldBe` True +(.<#) :: HasCallStack => TestCC -> String -> Expectation +cc .<# line = do + l <- dropTime <$> getTermLine cc + let suffix = line `isSuffixOf` l + unless suffix $ print ("expected to end with: " <> line, ", got: " <> l) + suffix `shouldBe` True + (<##..) :: HasCallStack => TestCC -> [String] -> Expectation cc <##.. ls = do l <- getTermLine cc @@ -414,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 @@ -492,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:" @@ -512,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:" @@ -547,6 +584,10 @@ withCCTransaction :: TestCC -> (DB.Connection -> IO a) -> IO a withCCTransaction cc action = withTransaction (chatStore $ chatController cc) $ \db -> action db +withCCAgentTransaction :: TestCC -> (DB.Connection -> IO a) -> IO a +withCCAgentTransaction TestCC {chatController = ChatController {smpAgent}} action = + withTransaction (agentClientStore smpAgent) $ \db -> action db + createCCNoteFolder :: TestCC -> IO () createCCNoteFolder cc = withCCTransaction cc $ \db -> @@ -616,30 +657,44 @@ showName (TestCC ChatController {currentUser} _ _ _ _ _) = do pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName createGroup2 :: HasCallStack => String -> TestCC -> TestCC -> IO () -createGroup2 gName cc1 cc2 = createGroup2' gName cc1 cc2 True +createGroup2 gName cc1 cc2 = createGroup2' gName cc1 (cc2, GRAdmin) True -createGroup2' :: HasCallStack => String -> TestCC -> TestCC -> Bool -> IO () -createGroup2' gName cc1 cc2 doConnectUsers = do +createGroup2' :: HasCallStack => String -> TestCC -> (TestCC, GroupMemberRole) -> Bool -> IO () +createGroup2' gName cc1 (cc2, role2) doConnectUsers = do when doConnectUsers $ connectUsers cc1 cc2 name2 <- userName cc2 cc1 ##> ("/g " <> gName) cc1 <## ("group #" <> gName <> " is created") cc1 <## ("to add members use /a " <> gName <> " or /create link #" <> gName) - addMember gName cc1 cc2 GRAdmin + addMember gName cc1 cc2 role2 cc2 ##> ("/j " <> gName) concurrently_ (cc1 <## ("#" <> gName <> ": " <> name2 <> " joined the group")) (cc2 <## ("#" <> gName <> ": you joined the group")) +disableFullDeletion2 :: HasCallStack => String -> TestCC -> TestCC -> IO () +disableFullDeletion2 gName cc1 cc2 = do + cc1 ##> ("/set delete #" <> gName <> " off") + cc1 <## "updated group preferences:" + cc1 <## "Full deletion: off" + name1 <- userName cc1 + cc2 <## (name1 <> " updated group #" <> gName <> ":") + cc2 <## "updated group preferences:" + cc2 <## "Full deletion: off" + createGroup3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () createGroup3 gName cc1 cc2 cc3 = do - createGroup2 gName cc1 cc2 + createGroup3' gName cc1 (cc2, GRAdmin) (cc3, GRAdmin) + +createGroup3' :: HasCallStack => String -> TestCC -> (TestCC, GroupMemberRole) -> (TestCC, GroupMemberRole) -> IO () +createGroup3' gName cc1 (cc2, role2) (cc3, role3) = do + createGroup2' gName cc1 (cc2, role2) True connectUsers cc1 cc3 name1 <- userName cc1 name3 <- userName cc3 sName2 <- showName cc2 sName3 <- showName cc3 - addMember gName cc1 cc3 GRAdmin + addMember gName cc1 cc3 role3 cc3 ##> ("/j " <> gName) concurrentlyN_ [ cc1 <## ("#" <> gName <> ": " <> name3 <> " joined the group"), @@ -651,10 +706,43 @@ createGroup3 gName cc1 cc2 cc3 = do cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] +createGroup4 :: HasCallStack => String -> TestCC -> (TestCC, GroupMemberRole) -> (TestCC, GroupMemberRole) -> (TestCC, GroupMemberRole) -> IO () +createGroup4 gName cc1 (cc2, role2) (cc3, role3) (cc4, role4) = do + createGroup3' gName cc1 (cc2, role2) (cc3, role3) + connectUsers cc1 cc4 + name1 <- userName cc1 + name4 <- userName cc4 + sName2 <- showName cc2 + sName3 <- showName cc3 + sName4 <- showName cc4 + addMember gName cc1 cc4 role4 + cc4 ##> ("/j " <> gName) + concurrentlyN_ + [ cc1 <## "#team: dan joined the group", + do + cc4 <## ("#" <> gName <> ": you joined the group") + cc4 <## ("#" <> gName <> ": member " <> sName2 <> " is connected") + cc4 <## ("#" <> gName <> ": member " <> sName3 <> " is connected"), + do + cc2 <## ("#" <> gName <> ": " <> name1 <> " added " <> sName4 <> " to the group (connecting...)") + cc2 <## ("#" <> gName <> ": new member " <> name4 <> " is connected"), + do + cc3 <## ("#" <> gName <> ": " <> name1 <> " added " <> sName4 <> " to the group (connecting...)") + cc3 <## ("#" <> gName <> ": new member " <> name4 <> " is connected") + ] + +disableFullDeletion3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () +disableFullDeletion3 gName cc1 cc2 cc3 = do + disableFullDeletion2 gName cc1 cc2 + name1 <- userName cc1 + cc3 <## (name1 <> " updated group #" <> gName <> ":") + cc3 <## "updated group preferences:" + cc3 <## "Full deletion: off" + create2Groups3 :: HasCallStack => String -> String -> TestCC -> TestCC -> TestCC -> IO () create2Groups3 gName1 gName2 cc1 cc2 cc3 = do createGroup3 gName1 cc1 cc2 cc3 - createGroup2' gName2 cc1 cc2 False + createGroup2' gName2 cc1 (cc2, GRAdmin) False name1 <- userName cc1 name3 <- userName cc3 addMember gName2 cc1 cc3 GRAdmin diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index 32e90cf754..bd74f44022 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -5,55 +5,55 @@ module JSONFixtures where import qualified Data.ByteString.Lazy.Char8 as LB noActiveUserSwift :: LB.ByteString -noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" +noActiveUserSwift = "{\"error\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}" noActiveUserTagged :: LB.ByteString -noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" +noActiveUserTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}" activeUserExistsSwift :: LB.ByteString -activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" +activeUserExistsSwift = "{\"error\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}" activeUserExistsTagged :: LB.ByteString -activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" +activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" chatStartedSwift :: LB.ByteString -chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" +chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" chatStartedTagged :: LB.ByteString -chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" +chatStartedTagged = "{\"result\":{\"type\":\"chatStarted\"}}" networkStatusesSwift :: LB.ByteString -networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" +networkStatusesSwift = "{\"result\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" networkStatusesTagged :: LB.ByteString -networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" +networkStatusesTagged = "{\"result\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" userJSON :: LB.ByteString userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" +memberSubSummarySwift = "{\"result\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" +memberSubSummaryTagged = "{\"result\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" +userContactSubSummarySwift = "{\"result\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" +userContactSubSummaryTagged = "{\"result\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" +pendingSubSummarySwift = "{\"result\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" +pendingSubSummaryTagged = "{\"result\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 615edc02c4..05d5362cf1 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -7,7 +7,10 @@ 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 @@ -19,81 +22,109 @@ markdownTests = do textWithUri textWithEmail textWithPhone + textWithMentions multilineMarkdownList +infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>> + +(==>) :: Text -> Markdown -> Expectation +s ==> m = parseMarkdown s `shouldBe` m + +(<==) :: Text -> Markdown -> Expectation +s <== m = s <<== markdownToList m + +(<==>) :: Text -> Markdown -> Expectation +s <==> m = (s ==> m) >> (s <== m) + +(==>>) :: Text -> MarkdownList -> Expectation +s ==>> ft = parseMaybeMarkdownList s `shouldBe` Just ft + +(<<==) :: Text -> MarkdownList -> Expectation +s <<== ft = T.concat (map markdownText ft) `shouldBe` s + +(<<==>>) :: Text -> MarkdownList -> Expectation +s <<==>> ft = (s ==>> ft) >> (s <<== ft) + +bold :: Text -> Markdown +bold = markdown Bold + textFormat :: Spec textFormat = describe "text format (bold)" do it "correct markdown" do - parseMarkdown "this is *bold formatted* text" - `shouldBe` "this is " <> markdown Bold "bold formatted" <> " text" - parseMarkdown "*bold formatted* text" - `shouldBe` markdown Bold "bold formatted" <> " text" - parseMarkdown "this is *bold*" - `shouldBe` "this is " <> markdown Bold "bold" - parseMarkdown " *bold* text" - `shouldBe` " " <> markdown Bold "bold" <> " text" - parseMarkdown " *bold* text" - `shouldBe` " " <> markdown Bold "bold" <> " text" - parseMarkdown "this is *bold* " - `shouldBe` "this is " <> markdown Bold "bold" <> " " - parseMarkdown "this is *bold* " - `shouldBe` "this is " <> markdown Bold "bold" <> " " + "this is *bold formatted* text" + <==> "this is " <> bold "bold formatted" <> " text" + "*bold formatted* text" + <==> bold "bold formatted" <> " text" + "this is *bold*" + <==> "this is " <> bold "bold" + " *bold* text" + <==> " " <> bold "bold" <> " text" + " *bold* text" + <==> " " <> bold "bold" <> " text" + "this is *bold* " + <==> "this is " <> bold "bold" <> " " + "this is *bold* " + <==> "this is " <> bold "bold" <> " " it "ignored as markdown" do - parseMarkdown "this is * unformatted * text" - `shouldBe` "this is * unformatted * text" - parseMarkdown "this is *unformatted * text" - `shouldBe` "this is *unformatted * text" - parseMarkdown "this is * unformatted* text" - `shouldBe` "this is * unformatted* text" - parseMarkdown "this is **unformatted** text" - `shouldBe` "this is **unformatted** text" - parseMarkdown "this is*unformatted* text" - `shouldBe` "this is*unformatted* text" - parseMarkdown "this is *unformatted text" - `shouldBe` "this is *unformatted text" + "this is * unformatted * text" + <==> "this is * unformatted * text" + "this is *unformatted * text" + <==> "this is *unformatted * text" + "this is * unformatted* text" + <==> "this is * unformatted* text" + "this is **unformatted** text" + <==> "this is **unformatted** text" + "this is*unformatted* text" + <==> "this is*unformatted* text" + "this is *unformatted text" + <==> "this is *unformatted text" + "*this* is *unformatted text" + <==> bold "this" <> " is *unformatted text" it "ignored internal markdown" do - parseMarkdown "this is *long _bold_ (not italic)* text" - `shouldBe` "this is " <> markdown Bold "long _bold_ (not italic)" <> " text" - parseMarkdown "snippet: `this is *bold text*`" - `shouldBe` "snippet: " <> markdown Snippet "this is *bold text*" + "this is *long _bold_ (not italic)* text" + <==> "this is " <> bold "long _bold_ (not italic)" <> " text" + "snippet: `this is *bold text*`" + <==> "snippet: " <> markdown Snippet "this is *bold text*" secretText :: Spec secretText = describe "secret text" do it "correct markdown" do - parseMarkdown "this is #black_secret# text" - `shouldBe` "this is " <> markdown Secret "black_secret" <> " text" - parseMarkdown "##black_secret### text" - `shouldBe` markdown Secret "#black_secret##" <> " text" - parseMarkdown "this is #black secret# text" - `shouldBe` "this is " <> markdown Secret "black secret" <> " text" - parseMarkdown "##black secret### text" - `shouldBe` markdown Secret "#black secret##" <> " text" - parseMarkdown "this is #secret#" - `shouldBe` "this is " <> markdown Secret "secret" - parseMarkdown " #secret# text" - `shouldBe` " " <> markdown Secret "secret" <> " text" - parseMarkdown " #secret# text" - `shouldBe` " " <> markdown Secret "secret" <> " text" - parseMarkdown "this is #secret# " - `shouldBe` "this is " <> markdown Secret "secret" <> " " - parseMarkdown "this is #secret# " - `shouldBe` "this is " <> markdown Secret "secret" <> " " + "this is #black_secret# text" + <==> "this is " <> markdown Secret "black_secret" <> " text" + "##black_secret### text" + <==> markdown Secret "#black_secret##" <> " text" + "this is #black secret# text" + <==> "this is " <> markdown Secret "black secret" <> " text" + "##black secret### text" + <==> markdown Secret "#black secret##" <> " text" + "this is #secret#" + <==> "this is " <> markdown Secret "secret" + " #secret# text" + <==> " " <> markdown Secret "secret" <> " text" + " #secret# text" + <==> " " <> markdown Secret "secret" <> " text" + "this is #secret# " + <==> "this is " <> markdown Secret "secret" <> " " + "this is #secret# " + <==> "this is " <> markdown Secret "secret" <> " " it "ignored as markdown" do - parseMarkdown "this is # unformatted # text" - `shouldBe` "this is # unformatted # text" - parseMarkdown "this is #unformatted # text" - `shouldBe` "this is #unformatted # text" - parseMarkdown "this is # unformatted# text" - `shouldBe` "this is # unformatted# text" - parseMarkdown "this is ## unformatted ## text" - `shouldBe` "this is ## unformatted ## text" - parseMarkdown "this is#unformatted# text" - `shouldBe` "this is#unformatted# text" - parseMarkdown "this is #unformatted text" - `shouldBe` "this is #unformatted text" + "this is # unformatted # text" + <==> "this is # unformatted # text" + "this is #unformatted # text" + <==> "this is #unformatted # text" + "this is # unformatted# text" + <==> "this is # unformatted# text" + "this is ## unformatted ## text" + <==> "this is ## unformatted ## text" + "this is#unformatted# text" + <==> "this is#unformatted# text" + "this is #unformatted text" + <==> "this is #unformatted text" + "*this* is #unformatted text" + <==> bold "this" <> " is #unformatted text" it "ignored internal markdown" do - parseMarkdown "snippet: `this is #secret_text#`" - `shouldBe` "snippet: " <> markdown Snippet "this is #secret_text#" + "snippet: `this is #secret_text#`" + <==> "snippet: " <> markdown Snippet "this is #secret_text#" red :: Text -> Markdown red = markdown (colored Red) @@ -101,71 +132,94 @@ red = markdown (colored Red) textColor :: Spec textColor = describe "text color (red)" do it "correct markdown" do - parseMarkdown "this is !1 red color! text" - `shouldBe` "this is " <> red "red color" <> " text" - parseMarkdown "!1 red! text" - `shouldBe` red "red" <> " text" - parseMarkdown "this is !1 red!" - `shouldBe` "this is " <> red "red" - parseMarkdown " !1 red! text" - `shouldBe` " " <> red "red" <> " text" - parseMarkdown " !1 red! text" - `shouldBe` " " <> red "red" <> " text" - parseMarkdown "this is !1 red! " - `shouldBe` "this is " <> red "red" <> " " - parseMarkdown "this is !1 red! " - `shouldBe` "this is " <> red "red" <> " " + "this is !1 red color! text" + <==> "this is " <> red "red color" <> " text" + "!1 red! text" + <==> red "red" <> " text" + "this is !1 red!" + <==> "this is " <> red "red" + " !1 red! text" + <==> " " <> red "red" <> " text" + " !1 red! text" + <==> " " <> red "red" <> " text" + "this is !1 red! " + <==> "this is " <> red "red" <> " " + "this is !1 red! " + <==> "this is " <> red "red" <> " " it "ignored as markdown" do - parseMarkdown "this is !1 unformatted ! text" - `shouldBe` "this is !1 unformatted ! text" - parseMarkdown "this is !1 unformatted ! text" - `shouldBe` "this is !1 unformatted ! text" - parseMarkdown "this is !1 unformatted! text" - `shouldBe` "this is !1 unformatted! text" - -- parseMarkdown "this is !!1 unformatted!! text" - -- `shouldBe` "this is " <> "!!1" <> "unformatted!! text" - parseMarkdown "this is!1 unformatted! text" - `shouldBe` "this is!1 unformatted! text" - parseMarkdown "this is !1 unformatted text" - `shouldBe` "this is !1 unformatted text" + "this is !1 unformatted ! text" + <==> "this is !1 unformatted ! text" + "this is !1 unformatted ! text" + <==> "this is !1 unformatted ! text" + "this is !1 unformatted! text" + <==> "this is !1 unformatted! text" + -- "this is !!1 unformatted!! text" + -- <==> "this is " <> "!!1" <> "unformatted!! text" + "this is!1 unformatted! text" + <==> "this is!1 unformatted! text" + "this is !1 unformatted text" + <==> "this is !1 unformatted text" + "*this* is !1 unformatted text" + <==> bold "this" <> " is !1 unformatted text" it "ignored internal markdown" do - parseMarkdown "this is !1 long *red* (not bold)! text" - `shouldBe` "this is " <> red "long *red* (not bold)" <> " text" - parseMarkdown "snippet: `this is !1 red text!`" - `shouldBe` "snippet: " <> markdown Snippet "this is !1 red text!" + "this is !1 long *red* (not bold)! text" + <==> "this is " <> red "long *red* (not bold)" <> " text" + "snippet: `this is !1 red text!`" + <==> "snippet: " <> markdown Snippet "this is !1 red text!" 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 it "correct markdown" do - parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat" - parseMarkdown "https://simplex.chat." `shouldBe` uri "https://simplex.chat" <> "." - parseMarkdown "https://simplex.chat, hello" `shouldBe` uri "https://simplex.chat" <> ", hello" - parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat" - parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat" - parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site" - parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat/" `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/" - parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat." `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "." - parseMarkdown "https://github.com/simplex-chat/ - SimpleX on GitHub" `shouldBe` uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" - -- parseMarkdown "SimpleX on GitHub (https://github.com/simplex-chat/)" `shouldBe` "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" - parseMarkdown "https://en.m.wikipedia.org/wiki/Servo_(software)" `shouldBe` uri "https://en.m.wikipedia.org/wiki/Servo_(software)" + "https://simplex.chat" <==> uri "https://simplex.chat" + "https://simplex.chat." <==> uri "https://simplex.chat" <> "." + "https://simplex.chat, hello" <==> uri "https://simplex.chat" <> ", hello" + "http://simplex.chat" <==> uri "http://simplex.chat" + "this is https://simplex.chat" <==> "this is " <> uri "https://simplex.chat" + "https://simplex.chat site" <==> uri "https://simplex.chat" <> " site" + "SimpleX on GitHub: https://github.com/simplex-chat/" <==> "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/" + "SimpleX on GitHub: https://github.com/simplex-chat." <==> "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "." + "https://github.com/simplex-chat/ - SimpleX on GitHub" <==> uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" + -- "SimpleX on GitHub (https://github.com/simplex-chat/)" <==> "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" + "https://en.m.wikipedia.org/wiki/Servo_(software)" <==> uri "https://en.m.wikipedia.org/wiki/Servo_(software)" + "example.com" <==> uri "example.com" + "example.com." <==> uri "example.com" <> "." + "example.com..." <==> uri "example.com" <> "..." + "www.example.com" <==> uri "www.example.com" + "example.academy" <==> uri "example.academy" + "this is example.com" <==> "this is " <> uri "example.com" + "x.com" <==> uri "x.com" it "ignored as markdown" do - parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat" - parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" + "_https://simplex.chat" <==> "_https://simplex.chat" + "this is _https://simplex.chat" <==> "this is _https://simplex.chat" + "this is https://" <==> "this is https://" + "example.c" <==> "example.c" + "www.www.example.com" <==> "www.www.example.com" + "www.example1.com" <==> "www.example1.com" + "www." <==> "www." + ".com" <==> ".com" + "example.academytoolong" <==> "example.academytoolong" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" - parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) - parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) - parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) + ("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) + ("simplex:" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) + ("https://example.com" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) let ct = "/contact#/?v=2&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" - parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) + ("https://simplex.chat" <> ct) <==> simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) + ("simplex:" <> ct) <==> simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("simplex:" <> ct) let gr = "/contact#/?v=2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" - parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + ("https://simplex.chat" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + ("simplex:" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("simplex:" <> gr) email :: Text -> Markdown email = Markdown $ Just Email @@ -173,15 +227,20 @@ email = Markdown $ Just Email textWithEmail :: Spec textWithEmail = describe "text with Email" do it "correct markdown" do - parseMarkdown "chat@simplex.chat" `shouldBe` email "chat@simplex.chat" - parseMarkdown "test chat@simplex.chat" `shouldBe` "test " <> email "chat@simplex.chat" - parseMarkdown "test chat+123@simplex.chat" `shouldBe` "test " <> email "chat+123@simplex.chat" - parseMarkdown "test chat.chat+123@simplex.chat" `shouldBe` "test " <> email "chat.chat+123@simplex.chat" - parseMarkdown "chat@simplex.chat test" `shouldBe` email "chat@simplex.chat" <> " test" - parseMarkdown "test1 chat@simplex.chat test2" `shouldBe` "test1 " <> email "chat@simplex.chat" <> " test2" - it "ignored as markdown" do - parseMarkdown "chat @simplex.chat" `shouldBe` "chat @simplex.chat" - parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat @simplex.chat" + "chat@simplex.chat" <==> email "chat@simplex.chat" + "test chat@simplex.chat" <==> "test " <> email "chat@simplex.chat" + "test chat+123@simplex.chat" <==> "test " <> email "chat+123@simplex.chat" + "test chat.chat+123@simplex.chat" <==> "test " <> email "chat.chat+123@simplex.chat" + "chat@simplex.chat test" <==> email "chat@simplex.chat" <> " test" + "test1 chat@simplex.chat test2" <==> "test1 " <> email "chat@simplex.chat" <> " test2" + "test chat@simplex.chat." <==> "test " <> email "chat@simplex.chat" <> "." + "test chat@simplex.chat..." <==> "test " <> email "chat@simplex.chat" <> "..." + it "ignored as email markdown" do + "chat @simplex.chat" <==> "chat " <> mention "simplex.chat" "@simplex.chat" + "this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat" + "this is chat@ simplex.chat" <==> "this is chat@ " <> uri "simplex.chat" + "this is chat @ simplex.chat" <==> "this is chat @ " <> uri "simplex.chat" + "*this* is chat @ simplex.chat" <==> bold "this" <> " is chat @ " <> uri "simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -189,20 +248,44 @@ phone = Markdown $ Just Phone textWithPhone :: Spec textWithPhone = describe "text with Phone" do it "correct markdown" do - parseMarkdown "07777777777" `shouldBe` phone "07777777777" - parseMarkdown "test 07777777777" `shouldBe` "test " <> phone "07777777777" - parseMarkdown "07777777777 test" `shouldBe` phone "07777777777" <> " test" - parseMarkdown "test1 07777777777 test2" `shouldBe` "test1 " <> phone "07777777777" <> " test2" - parseMarkdown "test 07777 777 777 test" `shouldBe` "test " <> phone "07777 777 777" <> " test" - parseMarkdown "test +447777777777 test" `shouldBe` "test " <> phone "+447777777777" <> " test" - parseMarkdown "test +44 (0) 7777 777 777 test" `shouldBe` "test " <> phone "+44 (0) 7777 777 777" <> " test" - parseMarkdown "test +44-7777-777-777 test" `shouldBe` "test " <> phone "+44-7777-777-777" <> " test" - parseMarkdown "test +44 (0) 7777.777.777 https://simplex.chat test" - `shouldBe` "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" + "07777777777" <==> phone "07777777777" + "test 07777777777" <==> "test " <> phone "07777777777" + "07777777777 test" <==> phone "07777777777" <> " test" + "test1 07777777777 test2" <==> "test1 " <> phone "07777777777" <> " test2" + "test 07777 777 777 test" <==> "test " <> phone "07777 777 777" <> " test" + "test +447777777777 test" <==> "test " <> phone "+447777777777" <> " test" + "test +44 (0) 7777 777 777 test" <==> "test " <> phone "+44 (0) 7777 777 777" <> " test" + "test +44-7777-777-777 test" <==> "test " <> phone "+44-7777-777-777" <> " test" + "test +44 (0) 7777.777.777 https://simplex.chat test" + <==> "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" it "ignored as markdown (too short)" $ - parseMarkdown "test 077777 test" `shouldBe` "test 077777 test" - it "ignored as markdown (double spaces)" $ - parseMarkdown "test 07777 777 777 test" `shouldBe` "test 07777 777 777 test" + "test 077777 test" <==> "test 077777 test" + it "ignored as markdown (double spaces)" $ do + "test 07777 777 777 test" <==> "test 07777 777 777 test" + "*test* 07777 777 777 test" <==> bold "test" <> " 07777 777 777 test" + +mention :: Text -> Text -> Markdown +mention = Markdown . Just . Mention + +textWithMentions :: Spec +textWithMentions = describe "text with mentions" do + it "correct markdown" do + "@alice" <==> mention "alice" "@alice" + "hello @alice" <==> "hello " <> mention "alice" "@alice" + "hello @alice !" <==> "hello " <> mention "alice" "@alice" <> " !" + "hello @alice!" <==> "hello " <> mention "alice" "@alice" <> "!" + "hello @alice..." <==> "hello " <> mention "alice" "@alice" <> "..." + "hello @alice@example.com" <==> "hello " <> mention "alice@example.com" "@alice@example.com" + "hello @'alice @ example.com'" <==> "hello " <> mention "alice @ example.com" "@'alice @ example.com'" + "@'alice jones'" <==> mention "alice jones" "@'alice jones'" + "hello @'alice jones'!" <==> "hello " <> mention "alice jones" "@'alice jones'" <> "!" + "hello @'a.j.'!" <==> "hello " <> mention "a.j." "@'a.j.'" <> "!" + it "ignored as markdown" $ do + "hello @'alice jones!" <==> "hello @'alice jones!" + "hello @bob @'alice jones!" <==> "hello " <> mention "bob" "@bob" <> " @'alice jones!" + "hello @ alice!" <==> "hello @ alice!" + "hello @bob @ alice!" <==> "hello " <> mention "bob" "@bob" <> " @ alice!" + "hello @bob @" <==> "hello " <> mention "bob" "@bob" <> " @" uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri @@ -210,15 +293,15 @@ uri' = FormattedText $ Just Uri multilineMarkdownList :: Spec multilineMarkdownList = describe "multiline markdown" do it "correct markdown" do - parseMaybeMarkdownList "http://simplex.chat\nhttp://app.simplex.chat" `shouldBe` Just [uri' "http://simplex.chat", "\n", uri' "http://app.simplex.chat"] + "http://simplex.chat\nhttp://app.simplex.chat" <<==>> [uri' "http://simplex.chat", "\n", uri' "http://app.simplex.chat"] it "combines the same formats" do - parseMaybeMarkdownList "http://simplex.chat\ntext 1\ntext 2\nhttp://app.simplex.chat" `shouldBe` Just [uri' "http://simplex.chat", "\ntext 1\ntext 2\n", uri' "http://app.simplex.chat"] + "http://simplex.chat\ntext 1\ntext 2\nhttp://app.simplex.chat" <<==>> [uri' "http://simplex.chat", "\ntext 1\ntext 2\n", uri' "http://app.simplex.chat"] it "no markdown" do parseMaybeMarkdownList "not a\nmarkdown" `shouldBe` Nothing let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" it "multiline with simplex link" do - parseMaybeMarkdownList ("https://simplex.chat" <> inv <> "\ntext") - `shouldBe` Just - [ FormattedText (Just $ SimplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), + ("https://simplex.chat" <> inv <> "\ntext") + <<==>> + [ FormattedText (simplexLinkFormat XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), "\ntext" ] diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 5c4ab29c60..11a89bc62e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -7,6 +7,7 @@ module MobileTests where +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent.STM import Control.Monad.Except @@ -28,7 +29,7 @@ import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures import Simplex.Chat.Controller (ChatController (..)) -import Simplex.Chat.Mobile +import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -38,6 +39,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -48,7 +50,7 @@ import System.FilePath (()) import System.IO (utf8) import Test.Hspec hiding (it) -mobileTests :: HasCallStack => SpecWith FilePath +mobileTests :: HasCallStack => SpecWith TestParams mobileTests = do describe "mobile API" $ do runIO $ do @@ -146,9 +148,10 @@ parsedMarkdown = parsedMarkdownTagged #endif -testChatApiNoUser :: FilePath -> IO () -testChatApiNoUser tmp = do - let dbPrefix = tmp "1" +testChatApiNoUser :: TestParams -> IO () +testChatApiNoUser ps = do + let tmp = tmpPath ps + dbPrefix = tmp "1" Right cc <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "myKey" "yesUp" chatSendCmd cc "/u" `shouldReturn` noActiveUser @@ -156,11 +159,12 @@ testChatApiNoUser tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUser chatSendCmd cc "/_start" `shouldReturn` chatStarted -testChatApi :: FilePath -> IO () -testChatApi tmp = do - let dbPrefix = tmp "1" +testChatApi :: TestParams -> IO () +testChatApi ps = do + let tmp = tmpPath ps + dbPrefix = tmp "1" f = dbPrefix <> chatSuffix - Right st <- createChatStore (DBOpts f "myKey" False True) MCYesUp + Right st <- createChatStore (DBOpts f "myKey" False True DB.TQOff) MCYesUp Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" @@ -169,13 +173,13 @@ testChatApi tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` networkStatuses - chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown -testMediaApi :: HasCallStack => FilePath -> IO () -testMediaApi tmp = do +testMediaApi :: HasCallStack => TestParams -> IO () +testMediaApi ps = do + let tmp = tmpPath ps Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" cc <- newStablePtr c key <- atomically $ C.randomBytes 32 g @@ -188,8 +192,9 @@ testMediaApi tmp = do B.length encrypted `shouldBe` B.length frame' runExceptT (chatDecryptMedia keyStr encrypted) `shouldReturn` Right frame' -testMediaCApi :: HasCallStack => FilePath -> IO () -testMediaCApi tmp = do +testMediaCApi :: HasCallStack => TestParams -> IO () +testMediaCApi ps = do + let tmp = tmpPath ps Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" cc <- newStablePtr c key <- atomically $ C.randomBytes 32 g @@ -217,8 +222,9 @@ instance FromJSON WriteFileResult where instance FromJSON ReadFileResult where parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) -testFileCApi :: FilePath -> FilePath -> IO () -testFileCApi fileName tmp = do +testFileCApi :: FilePath -> TestParams -> IO () +testFileCApi fileName ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp src <- B.readFile "./tests/fixtures/test.pdf" let path = tmp (fileName <> ".pdf") @@ -242,8 +248,9 @@ testFileCApi fileName tmp = do contents `shouldBe` src sz' `shouldBe` len -testMissingFileCApi :: FilePath -> IO () -testMissingFileCApi tmp = do +testMissingFileCApi :: TestParams -> IO () +testMissingFileCApi ps = do + let tmp = tmpPath ps let path = tmp "missing_file" cPath <- newCString path CFArgs key nonce <- atomically . CF.randomArgs =<< C.newRandom @@ -254,8 +261,9 @@ testMissingFileCApi tmp = do err <- peekCAString (ptr `plusPtr` 1) err `shouldContain` "missing_file: openBinaryFile: does not exist" -testFileEncryptionCApi :: FilePath -> FilePath -> IO () -testFileEncryptionCApi fileName tmp = do +testFileEncryptionCApi :: FilePath -> TestParams -> IO () +testFileEncryptionCApi fileName ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp let fromPath = tmp (fileName <> ".source.pdf") copyFile "./tests/fixtures/test.pdf" fromPath @@ -273,8 +281,9 @@ testFileEncryptionCApi fileName tmp = do "" <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' B.readFile toPath' `shouldReturn` src -testMissingFileEncryptionCApi :: FilePath -> IO () -testMissingFileEncryptionCApi tmp = do +testMissingFileEncryptionCApi :: TestParams -> IO () +testMissingFileEncryptionCApi ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp let fromPath = tmp "missing_file.source.pdf" toPath = tmp "missing_file.encrypted.pdf" @@ -294,7 +303,7 @@ testMissingFileEncryptionCApi tmp = do mkCCPtr :: FilePath -> IO (StablePtr ChatController) mkCCPtr tmp = either (error . show) newStablePtr =<< chatMigrateInit (tmp "1") "" "yesUp" -testValidNameCApi :: FilePath -> IO () +testValidNameCApi :: TestParams -> IO () testValidNameCApi _ = do let goodName = "Джон Доу 👍" cName1 <- cChatValidName =<< newCString goodName @@ -302,7 +311,7 @@ testValidNameCApi _ = do cName2 <- cChatValidName =<< newCString " @'Джон' Доу 👍 " peekCString cName2 `shouldReturn` goodName -testChatJsonLengthCApi :: FilePath -> IO () +testChatJsonLengthCApi :: TestParams -> IO () testChatJsonLengthCApi _ = do cInt1 <- cChatJsonLength =<< newCString "Hello!" cInt1 `shouldBe` 6 diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 0a00d7b83c..656f0ae0e2 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -19,9 +19,11 @@ 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) +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Protocol import Test.Hspec diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index aba67ed034..61e75d116b 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 @@ -101,13 +101,13 @@ testChatPreferences :: Maybe Preferences testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), contactLink = Nothing, preferences = testChatPreferences} testGroupProfile :: GroupProfile -testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, image = Nothing, groupPreferences = testGroupPreferences} +testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, image = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do @@ -116,10 +116,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)) it "x.msg.new simple text - timed message TTL" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) it "x.msg.new simple text - live message" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) it "x.msg.new simple link" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing)) @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-12\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-15\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -146,22 +146,22 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing (Just 3600) Nothing))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing Nothing))) it "x.msg.new quote - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing Nothing (Just True)))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True) Nothing))) it "x.msg.new forward" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) it "x.msg.new forward - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) it "x.msg.new simple text with file" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.update" $ "{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") Nothing Nothing + #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing @@ -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,40 +237,40 @@ 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==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"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\"}}}}}}" + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"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.intro\",\"params\":{\"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\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"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-12\",\"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\"}}}}}" @@ -283,7 +283,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemConAll (MemberId "\1\2\3\4") it "x.grp.mem.del" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" - #==# XGrpMemDel (MemberId "\1\2\3\4") + #==# XGrpMemDel (MemberId "\1\2\3\4") False it "x.grp.leave" $ "{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave @@ -291,11 +291,11 @@ 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\"}}}" - #==# XGrpDirectInv testConnReq (Just $ MCText "hello") + "{\"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") Nothing 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\"}}" - #==# XGrpDirectInv testConnReq Nothing + "{\"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 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\"}}}" -- #==# XGrpMsgForward diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index dd4032e274..f18b097e9c 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -6,6 +6,7 @@ module RemoteTests where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Logger.Simple import qualified Data.Aeson as J @@ -26,7 +27,7 @@ import UnliftIO import UnliftIO.Concurrent import UnliftIO.Directory -remoteTests :: SpecWith FilePath +remoteTests :: SpecWith TestParams remoteTests = describe "Remote" $ do describe "protocol handshake" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False @@ -45,7 +46,7 @@ remoteTests = describe "Remote" $ do -- * Chat commands -remoteHandshakeTest :: HasCallStack => Bool -> FilePath -> IO () +remoteHandshakeTest :: HasCallStack => Bool -> TestParams -> IO () remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" @@ -74,7 +75,7 @@ remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \m mobile ##> "/list remote ctrls" mobile <## "No remote controllers" -remoteHandshakeStoredTest :: HasCallStack => FilePath -> IO () +remoteHandshakeStoredTest :: HasCallStack => TestParams -> IO () remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do logNote "Starting new session" startRemote mobile desktop @@ -95,7 +96,7 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile startRemoteStored mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) -remoteHandshakeDiscoverTest :: HasCallStack => FilePath -> IO () +remoteHandshakeDiscoverTest :: HasCallStack => TestParams -> IO () remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do logNote "Preparing new session" startRemote mobile desktop @@ -105,7 +106,7 @@ remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobi startRemoteDiscover mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) -remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () +remoteHandshakeRejectTest :: HasCallStack => TestParams -> IO () remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do logNote "Starting new session" startRemote mobile desktop @@ -135,7 +136,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil desktop <## "remote host 1 connected" stopMobile mobile desktop -storedBindingsTest :: HasCallStack => FilePath -> IO () +storedBindingsTest :: HasCallStack => TestParams -> IO () storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/set device name My desktop" desktop <## "ok" @@ -166,7 +167,7 @@ storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile deskto -- TODO: more parser tests -remoteMessageTest :: HasCallStack => FilePath -> IO () +remoteMessageTest :: HasCallStack => TestParams -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop contactBob desktop bob @@ -192,7 +193,7 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob threadDelay 1000000 logNote "done" -remoteStoreFileTest :: HasCallStack => FilePath -> IO () +remoteStoreFileTest :: HasCallStack => TestParams -> IO () remoteStoreFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do @@ -322,7 +323,7 @@ remoteStoreFileTest = r `shouldStartWith` "remote host 1 error" r `shouldContain` err -remoteCLIFileTest :: HasCallStack => FilePath -> IO () +remoteCLIFileTest :: HasCallStack => TestParams -> IO () remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) @@ -391,7 +392,7 @@ remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob stopMobile mobile desktop -switchRemoteHostTest :: FilePath -> IO () +switchRemoteHostTest :: TestParams -> IO () switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop contactBob desktop bob @@ -417,7 +418,7 @@ switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \ desktop <## "remote host 1 error: RHEInactive" desktop ##> "/contacts" -indicateRemoteHostTest :: FilePath -> IO () +indicateRemoteHostTest :: TestParams -> IO () indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do connectUsers desktop cath startRemote mobile desktop @@ -441,7 +442,7 @@ indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile c desktop <##> cath cath <##> desktop -multipleProfilesTest :: FilePath -> IO () +multipleProfilesTest :: TestParams -> IO () multipleProfilesTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do connectUsers desktop cath diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 307e715dfb..807fa4a0cb 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -1,19 +1,33 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module SchemaDump where import ChatClient (withTmpFiles) +import ChatTests.DBUtils +import Control.Concurrent.STM import Control.DeepSeq +import qualified Control.Exception as E import Control.Monad (unless, void) -import Data.List (dropWhileEnd) +import Data.List (dropWhileEnd, sort) +import qualified Data.Map.Strict as M import Data.Maybe (fromJust, isJust) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as T +import Database.SQLite.Simple (Query (..)) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store +import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) +import Simplex.Messaging.Agent.Store.Common (withConnection) +import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface -import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Util (ifM, whenM) +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Util (ifM, tshow, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) import Test.Hspec @@ -21,25 +35,21 @@ import Test.Hspec testDB :: FilePath testDB = "tests/tmp/test_chat.db" +testAgentDB :: FilePath +testAgentDB = "tests/tmp/test_agent.db" + appSchema :: FilePath appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" --- Some indexes found by `.lint fkey-indexes` are not added to schema, explanation: --- --- - CREATE INDEX 'chat_items_group_id' ON 'chat_items'('group_id'); --> groups(group_id) --- --- Covering index is used instead. See for example: --- EXPLAIN QUERY PLAN DELETE FROM groups; --- (uses idx_chat_items_groups_item_status) --- --- - CREATE INDEX 'connections_group_member_id' ON 'connections'('group_member_id'); --> group_members(group_member_id) --- --- Covering index is used instead. See for example: --- EXPLAIN QUERY PLAN DELETE FROM group_members; --- (uses idx_connections_group_member) appLint :: FilePath appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" +appChatQueryPlans :: FilePath +appChatQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt" + +appAgentQueryPlans :: FilePath +appAgentQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt" + testSchema :: FilePath testSchema = "tests/tmp/test_agent_schema.sql" @@ -53,7 +63,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) MCError getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -61,14 +71,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) MCError getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore (DBOpts testDB "" False True) noDownMigrations MCError + Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -80,7 +90,8 @@ testSchemaMigrations = withTmpFiles $ do schema <- getSchema testDB testSchema Migrations.run st True $ MTRUp [m] schema' <- getSchema testDB testSchema - schema' `shouldNotBe` schema + unless (name m `elem` skipComparisonForUpMigrations) $ + schema' `shouldNotBe` schema Migrations.run st True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema @@ -89,6 +100,12 @@ testSchemaMigrations = withTmpFiles $ do schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' +skipComparisonForUpMigrations :: [String] +skipComparisonForUpMigrations = + [ -- schema doesn't change + "20250129_delete_unused_contacts" + ] + skipComparisonForDownMigrations :: [String] skipComparisonForDownMigrations = [ -- on down migration msg_delivery_events table moves down to the end of the file @@ -106,7 +123,11 @@ skipComparisonForDownMigrations = -- sequence table moves down to the end of the file "20241023_chat_item_autoincrement_id", -- indexes move down to the end of the file - "20241125_indexes" + "20241125_indexes", + -- indexes move down to the end of the file + "20250130_indexes", + -- index moves down to the end of the file + "20250227_member_acceptance" ] getSchema :: FilePath -> FilePath -> IO String @@ -120,3 +141,45 @@ getLintFKeyIndexes dbPath lintPath = do void $ readCreateProcess (shell $ "sqlite3 " <> dbPath <> " '.lint fkey-indexes' > " <> lintPath) "" lint <- readFile lintPath lint `deepseq` pure lint + +saveQueryPlans :: SpecWith TestParams +saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryStats, agentQueryStats} -> do + (chatSavedPlans, chatSavedPlans') <- + updatePlans + appChatQueryPlans + chatQueryStats + (createChatStore (DBOpts testDB "" False True TQOff) MCError) + (\db -> do + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" + ) + (agentSavedPlans, agentSavedPlans') <- + updatePlans + appAgentQueryPlans + agentQueryStats + (createAgentStore (DBOpts testAgentDB "" False True TQOff) MCError) + (const $ pure ()) + chatSavedPlans' == chatSavedPlans `shouldBe` True + agentSavedPlans' == agentSavedPlans `shouldBe` True + removeFile testDB + removeFile testAgentDB + where + updatePlans plansFile statsSel createStore prepareStore = do + savedPlans <- ifM (doesFileExist plansFile) (T.readFile plansFile) (pure "") + savedPlans `deepseq` pure () + queries <- sort . M.keys <$> readTVarIO statsSel + Right st <- createStore + plans' <- withConnection st $ \db -> do + void $ prepareStore db + mapM (getQueryPlan db) queries + let savedPlans' = T.unlines plans' + T.writeFile plansFile savedPlans' + pure (savedPlans, savedPlans') + getQueryPlan :: DB.Connection -> Query -> IO Text + getQueryPlan db q = + (("Query: " <> fromQuery q) <>) . result <$> E.try (DB.query_ db $ "explain query plan " <> q) + result = \case + Right r -> "\nPlan:\n" <> T.unlines (map planDetail r) + Left (e :: E.SomeException) -> "\nError: " <> tshow e <> "\n" + planDetail :: (Int, Int, Int, Text) -> Text + planDetail (_, _, _, detail) = detail diff --git a/tests/Test.hs b/tests/Test.hs index 042f699d3f..1d8d45ebb4 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,9 +1,13 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TupleSections #-} import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient import ChatTests +import ChatTests.DBUtils import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System @@ -21,6 +25,7 @@ import ViewTests #if defined(dbPostgres) import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) #else +import qualified Simplex.Messaging.TMap as TM import MobileTests import SchemaDump import WebRTCTests @@ -29,6 +34,10 @@ import WebRTCTests main :: IO () main = do setLogLevel LogError +#if !defined(dbPostgres) + chatQueryStats <- TM.emptyIO + agentQueryStats <- TM.emptyIO +#endif withGlobalLogging logCfg . hspec #if defined(dbPostgres) . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) @@ -48,9 +57,11 @@ main = do describe "Message batching" batchingTests describe "Operators" operatorTests describe "Random servers" randomServersTests - around testBracket #if defined(dbPostgres) + around testBracket . after_ (dropAllSchemasExceptSystem testDBConnectInfo) +#else + around (testBracket chatQueryStats agentQueryStats) #endif $ do #if !defined(dbPostgres) @@ -60,8 +71,16 @@ main = do xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests describe "Remote session" remoteTests +#if !defined(dbPostgres) + xdescribe'' "Save query plans" saveQueryPlans +#endif where - testBracket test = withSmpServer $ tmpBracket test +#if defined(dbPostgres) + testBracket test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, printOutput = False} +#else + testBracket chatQueryStats agentQueryStats test = + withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats, printOutput = False} +#endif tmpBracket test = do t <- getSystemTime let ts = show (systemSeconds t) <> show (systemNanoseconds t) diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index bbc9c853ea..22ac4a695d 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -21,6 +21,7 @@ testMkValidName = do mkValidName "J . . Doe" `shouldBe` "J . Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" + mkValidName "'alice" `shouldBe` "alice" mkValidName " alice" `shouldBe` "alice" mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" @@ -37,3 +38,5 @@ testMkValidName = do mkValidName "alice@example.com" `shouldBe` "alice@example.com" mkValidName "alice <> bob" `shouldBe` "alice <> bob" mkValidName "alice -> bob" `shouldBe` "alice -> bob" + mkValidName "01234567890123456789012345678901234567890123456789extra" `shouldBe` "01234567890123456789012345678901234567890123456789" + mkValidName "0123456789012345678901234567890123456789012345678 extra" `shouldBe` "0123456789012345678901234567890123456789012345678" diff --git a/website/langs/ar.json b/website/langs/ar.json index f257c1c747..0c1fb4515a 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -29,9 +29,9 @@ "simplex-explained-tab-2-text": "2. كيف يعمل", "simplex-explained-tab-1-p-2": "كيف يمكن أن تعمل مع قوائم انتظار أحادية الاتجاه وبدون معرّفات ملف تعريف المستخدم؟", "simplex-explained-tab-2-p-1": "لكل اتصال، تستخدم قائمتي انتظار منفصلتين للمُراسلة لإرسال واستلام الرسائل عبر خوادم مختلفة.", - "simplex-explained-tab-2-p-2": "تقوم الخوادم بتمرير الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", + "simplex-explained-tab-2-p-2": "تمرّر الخوادم الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", "simplex-explained-tab-3-p-1": "تحتوي الخوادم على بيانات اعتماد مجهولة منفصلة لكل قائمة انتظار، ولا تعرف المستخدمين الذين ينتمون إليهم.", - "copyright-label": "مشروع مفتوح المصدر © SimpleX 2020-2024", + "copyright-label": "مشروع مفتوح المصدر © SimpleX 2020-2025", "simplex-chat-protocol": "بروتوكول دردشة SimpleX", "developers": "المطورين", "hero-subheader": "أول نظام مُراسلة
دون معرّفات مُستخدم", @@ -51,13 +51,13 @@ "simplex-private-5-title": "طبقات متعددة من
حشوة المحتوى", "simplex-private-7-title": "التحقق
من سلامة الرسالة", "simplex-private-8-title": "خلط الرسائل
لتقليل من الارتباط", - "simplex-private-10-title": "معرفات زوجية مجهولة مؤقتة", + "simplex-private-10-title": "معرّفات زوجية مجهولة مؤقتة", "simplex-private-card-3-point-1": "يتم استخدام TLS 1.2 / 1.3 مع خوارزميات قوية فقط لاتصالات الخادم والعميل.", "simplex-private-card-3-point-2": "تعمل بصمة الخادم وربط القناة على منع هجمات الوسيط (MITM) وإعادة التشغيل.", "simplex-private-card-3-point-3": "استئناف الاتصال معطل لمنع هجمات الجلسة.", "simplex-private-card-4-point-1": "لحماية عنوان IP الخاص بك، يمكنك الوصول إلى الخوادم عبر تور أو بعض شبكات تراكب النقل الأخرى.", "simplex-private-card-5-point-1": "يستخدم SimpleX حشوة المحتوى لكل طبقة تعمية لإحباط هجمات حجم الرسالة.", - "simplex-private-card-6-point-2": "لمنع ذلك، تقوم تطبيقات SimpleX بتمرير مفاتيح لمرة واحدة خارج النطاق، عند مشاركة عنوان كرابط أو رمز QR.", + "simplex-private-card-6-point-2": "لمنع ذلك، تقوم تطبيقات SimpleX بتمرير مفاتيح لمرة واحدة خارج النطاق، عندما تُشارك عنوان كرابط أو رمز QR.", "simplex-private-card-8-point-1": "تعمل خوادم SimpleX كعقد مختلطة بزمن انتقال منخفض — الرسائل الواردة والصادرة لها ترتيب مختلف.", "simplex-private-card-9-point-1": "كل رسالة انتظار تمرر الرسائل في اتجاه واحد، بعناوين إرسال واستلام مختلفة.", "simplex-private-card-9-point-2": "إنه يقلل من نواقل الهجوم، مقارنة بوسطاء الرسائل التقليديين، والبيانات الوصفية المتاحة.", @@ -74,7 +74,7 @@ "simplex-unique-1-overlay-1-title": "الخصوصية الكاملة لهويتك وملفك الشخصي وجهات الاتصال والبيانات الوصفية", "simplex-unique-2-title": "أنت محمي
من رسائل الإزعاج وإساءة الاستخدام", "simplex-unique-3-title": "أنت تتحكم في بياناتك", - "simplex-unique-3-overlay-1-title": "ملكية ومراقبة وأمان بياناتك", + "simplex-unique-3-overlay-1-title": "الملكية والتحكم وأمن بياناتك", "simplex-unique-4-title": "أنت تمتلك شبكة SimpleX", "simplex-unique-4-overlay-1-title": "لامركزية بالكامل — يمتلك المستخدمون شبكة SimpleX", "hero-overlay-card-1-p-4": "هذا التصميم يمنع تسريب أي البيانات الوصفية للمستخدمين على مستوى التطبيق. لزيادة تحسين الخصوصية وحماية عنوان IP الخاص بك، يمكنك الاتصال بخوادم المراسلة عبر Tor.", @@ -85,23 +85,23 @@ "privacy-matters-overlay-card-1-p-2": "يعرف تجار التجزئة عبر الإنترنت أن الأشخاص ذوي الدخل المنخفض هم أكثر عرضة لإجراء عمليات شراء عاجلة، لذلك قد يفرضون أسعارًا أعلى أو يزيلون الخصومات.", "simplex-private-6-title": "تبديل
خارج النطاق", "simplex-private-9-title": "قوائم انتظار
أحادية الاتجاه", - "privacy-matters-overlay-card-1-p-4": "تحمي منصة SimpleX خصوصية اتصالاتك بشكل أفضل من أي بديل آخر، مما يمنع تمامًا الرسم البياني الاجتماعي الخاص بك من أن يصبح متاحًا لأي شركات أو مؤسسات. حتى عندما يستخدم الأشخاص الخوادم التي توفرها SimpleX Chat، فإننا لا نعرف عدد المستخدمين أو اتصالاتهم.", + "privacy-matters-overlay-card-1-p-4": "تحمي شبكة SimpleX خصوصية اتصالاتك بشكل أفضل من أي بديل آخر، مما يمنع تمامًا الرسم البياني الاجتماعي الخاص بك من أن يصبح متاحًا لأي شركات أو مؤسسات. حتى عندما يستخدم الأشخاص الخوادم التي توفرها SimpleX Chat، فإننا لا نعرف عدد المستخدمين أو اتصالاتهم.", "simplex-private-card-1-point-1": "بروتوكول السقاطة المزدوجة —
رسائل OTR مع السرية المستمرة واستعادة الاختراق.", "simplex-private-card-1-point-2": "NaCL cryptobox في كل قائمة انتظار لمنع ارتباط حركة مرور البيانات بين قوائم انتظار الرسائل في حالة اختراق TLS.", "simplex-private-card-2-point-1": "طبقة إضافية من تعمية الخادم للتسليم إلى المُستلم، لمنع الارتباط بين حركة مرور بيانات الخادم المُستلمة والمُرسلة في حالة اختراق TLS.", "simplex-private-card-4-point-2": "لاستخدام SimpleX عبر تور، يُرجى تثبيت تطبيق Orbot وتمكّين وكيل SOCKS5 (أو VPN على iOS ).", "simplex-private-card-5-point-2": "يجعل الرسائل ذات الأحجام المختلفة تبدو متشابهة للخوادم ومراقبي الشبكة.", - "simplex-private-card-6-point-1": "العديد من منصات الاتصال عرضة لهجمات الوسيط (MITM) من قبل الخوادم أو موفري الشبكات.", + "simplex-private-card-6-point-1": "العديد من شبكات التواصل عرضة لهجمات الوسيط (MITM) من قِبل الخوادم أو موفري الشبكات.", "simplex-private-card-7-point-1": "لضمان سلامة الرسائل يتم ترقيمها بالتسلسل وتضمين تجزئة الرسالة السابقة.", "simplex-private-card-7-point-2": "إذا أُضيفت أي رسالة أو أُزيلت أو تغيّرت، فسيتم تنبيه المُستلم.", "simplex-private-card-10-point-2": "يسمح بتسليم الرسائل بدون معرّفات ملف تعريف المستخدم، مما يوفر خصوصية للبيانات الوصفية أفضل من البدائل.", "privacy-matters-2-overlay-1-linkText": "تمنحك الخصوصية القوة", "simplex-unique-2-overlay-1-title": "أفضل حماية من رسائل الإزعاج وإساءة الاستخدام", - "hero-overlay-card-1-p-3": "أنت تحدد الخادم (الخوادم) المراد استخدامه لاستلام الرسائل وجهات الاتصال الخاصة بك — الخوادم التي تستخدمها لإرسال الرسائل إليهم. من المرجح أن تستخدم كل مُحادثة خادمين مختلفين.", + "hero-overlay-card-1-p-3": "أنت تحدد الخادم (الخوادم) المراد استخدامه لاستلام الرسائل وجهات اتصالك — الخوادم التي تستخدمها لإرسال الرسائل إليهم. من المرجح أن تستخدم كل مُحادثة خادمين مختلفين.", "hero-overlay-card-1-p-1": "سأل العديد من المستخدمين: إذا لم يكن لدى SimpleX معرّفات مستخدم، فكيف يمكنها معرفة مكان تسليم الرسائل؟ ", - "hero-overlay-card-1-p-2": "لتوصيل الرسائل، بدلاً من معرفات المستخدم التي تستخدمها جميع المنصات الأخرى، يستخدم SimpleX معرّفات مزدوجة مؤقتة مجهولة الهوية لقوائم انتظار الرسائل، مختلفة لكل اتصال من اتصالاتك — لا توجد معرفات مستخدم دائمة.", + "hero-overlay-card-1-p-2": "لتسليم الرسائل، بدلاً من معرّفات المُستخدم المُستخدمة من قِبل جميع الشبكات الأخرى، يستخدم SimpleX معرّفات زوجية مجهولة مؤقتة لقوائم الرسائل، منفصلة لكل اتصال من اتصالاتك — ولا توجد معرّفات طويلة الأجل.", "simplex-network-overlay-card-1-p-1": "بروتوكولات المُراسلة والتطبيقات P2P بها مشاكل مختلفة تجعلها أقل موثوقية من SimpleX وأكثر تعقيدًا في التحليل و عرضة لعدة أنواع من الهجمات.", - "hero-overlay-card-2-p-1": "عندما يكون لدى المستخدمين هويات ثابتة، حتى لو كان هذا مجرد رقم عشوائي، مثل معرف الجلسة، فهناك خطر يتمثل في أن الموفر أو المهاجم يمكنه مراقبة كيفية اتصال المستخدمين وعدد الرسائل التي يرسلونها.", + "hero-overlay-card-2-p-1": "عندما يكون لدى المستخدمين هويات ثابتة، حتى لو كان هذا مجرد رقم عشوائي، مثل معرّف الجلسة، فهناك خطر يتمثل في أن الموفر أو المهاجم يمكنه مراقبة كيفية اتصال المستخدمين وعدد الرسائل التي يرسلونها.", "hero-overlay-card-1-p-6": "اقرأ المزيد في SimpleX whitepaper .", "hero-overlay-card-2-p-3": "حتى مع معظم التطبيقات الخاصة التي تستخدم خدمات Tor v3، إذا تحدثت إلى جهتي اتصال مختلفتين عبر نفس الملف الشخصي، فيمكنهما إثبات أنهما متصلان بنفس الشخص.", "hero-overlay-card-2-p-4": "يحمي SimpleX من هذه الهجمات من خلال عدم وجود أي معرّفات مستخدم في تصميمه. وإذا كنت تستخدم وضع التخفي، فسيكون لديك اسم عرض مختلف لكل جهة اتصال، مع تجنب أي بيانات مشتركة بينهما.", @@ -112,29 +112,28 @@ "simplex-network-overlay-card-1-li-5": "قد تكون جميع شبكات P2P المعروفة عرضة لهجوم Sybil، لأن كل عقدة قابلة للاكتشاف، وتعمل الشبكة ككل. تتطلب الإجراءات المعروفة لتخفيفها إما مكونًا مركزيًا أو إثبات عمل مكلف . لا تحتوي شبكة SimpleX على إمكانية اكتشاف الخادم، فهي مجزأة وتعمل كشبكات فرعية متعددة ومعزولة، مما يجعل الهجمات على مستوى الشبكة مستحيلة.", "privacy-matters-overlay-card-3-p-1": "يجب على الجميع الاهتمام بخصوصية وأمان اتصالاتهم — يمكن للمُحادثات غير الضارة أن تعرضك للخطر، حتى لو لم يكن لديك ما تخفيه.", "privacy-matters-overlay-card-3-p-4": "لا يكفي استخدام برنامج مُراسلة مُعمَّاة بين الطرفين، يجب علينا جميعًا استخدام برامج مُراسلة التي تحمي خصوصية شبكاتنا الشخصية — مع من نحن مرتبطون.", - "simplex-unique-overlay-card-1-p-3": "يحمي هذا التصميم خصوصية الأشخاص الذين تتواصل معهم، ويخفيها عن خوادم منصة SimpleX ومن أي مراقبين. لإخفاء عنوان IP الخاص بك من الخوادم، يمكنك الاتصال بخوادم SimpleX عبر تور.", - "simplex-unique-overlay-card-2-p-1": "نظرًا لعدم وجود معرف لديك على نظام SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", + "simplex-unique-overlay-card-1-p-3": "يحمي هذا التصميم خصوصية الأشخاص الذين تتواصل معهم، ويخفيها عن خوادم شبكة SimpleX ومن أي مراقبين. لإخفاء عنوان IP الخاص بك من الخوادم، يمكنك الاتصال بخوادم SimpleX عبر تور.", + "simplex-unique-overlay-card-2-p-1": "نظرًا لعدم وجود معرّف لديك على شبكة SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", "simplex-unique-overlay-card-2-p-2": "حتى مع عنوان المستخدم الاختياري، بينما يمكن استخدامه لإرسال طلبات جهات اتصال مزعجة، يمكنك تغييره أو حذفه بالكامل دون فقد أي من اتصالاتك.", "simplex-unique-overlay-card-3-p-2": "يتم الاحتفاظ بالرسائل المُعمَّاة بين الطرفين مؤقتًا على خوادم ترحيل SimpleX حتى يتم استلامها، ثُمَّ تُحذف نهائيًا.", - "simplex-unique-overlay-card-3-p-4": "لا توجد معرفّات أو نص مُعَمَّى مشترك بين حركة مرور بيانات الخادم المُرسلة والمُستلمة — ؛ إذا كان أي شخص يراقب ذلك، فلن يتمكّن بسهولة من تحديد من يتواصل مع من، حتى لو اختُرق TLS.", - "simplex-unique-card-1-p-1": "يحمي SimpleX خصوصية ملف التعريف الخاص بك، جهات الاتصال والبيانات الوصفية، ويخفيه عن خوادم منصة SimpleX وأي مراقبين.", + "simplex-unique-overlay-card-3-p-4": "لا توجد معرفّات أو نص مُعَمَّى مشترك بين حركة مرور بيانات الخادم المُرسلة والمُستلمة — إذا كان أي شخص يراقب ذلك، فلن يتمكّن بسهولة من تحديد من يتواصل مع من، حتى لو اختُرق TLS.", + "simplex-unique-card-1-p-1": "يحمي SimpleX خصوصية ملف تعريفك، جهات اتصالك والبيانات الوصفية، ويخفيه عن خوادم شبكة SimpleX وأي مراقبين.", "privacy-matters-overlay-card-2-p-1": "منذ وقت ليس ببعيد، لاحظنا أن الانتخابات الرئيسية يتم التلاعب بها بواسطة شركة استشارية ذات سمعة طيبة التي استخدمت الرسوم البيانية الاجتماعية لتشويه نظرتنا للعالم الحقيقي والتلاعب بأصواتنا.", - "privacy-matters-overlay-card-2-p-2": "لكي تكون موضوعيًا وتتخذ قرارات مستقلة، يجب أن تكون متحكمًا في مساحة المعلومات الخاصة بك. هذا ممكن فقط إذا كنت تستخدم منصة اتصالات خاصة لا يمكنها الوصول إلى الرسم البياني الاجتماعي الخاص بك.", - "privacy-matters-overlay-card-2-p-3": "SimpleX هو النظام الأساسي الأول الذي لا يحتوي على أي معرّفات مستخدم صمّم ليكون خاصًا، وبهذه الطريقة تحمي مخطط اتصالاتك بشكل أفضل من أي بديل معروف.", + "privacy-matters-overlay-card-2-p-2": "لكي تكون موضوعيًا وتتخذ قرارات مستقلة، عليك التحكم في مساحة معلوماتك. لا يمكن تحقيق ذلك إلا باستخدام شبكة اتصال خاصة لا يمكنها الوصول إلى حسابك على مواقع التواصل الاجتماعي.", + "privacy-matters-overlay-card-2-p-3": "SimpleX هي أول شبكة لا تحتوي على أي معرّفات مستخدم من حيث التصميم، وبهذه الطريقة تحمي مخطط اتصالاتك بشكل أفضل من أي بديل معروف.", "privacy-matters-overlay-card-3-p-2": "واحدة من أكثر القصص إثارة للصدمة هي تجربة محمدو ولد صلاحي الموصوفة في مذكراته والموضحة في فيلم موريتاني. تم وضعه في معتقل غوانتانامو بدون محاكمة، وتعرض للتعذيب هناك لمدة 15 عامًا بعد مكالمة هاتفية مع قريبه في أفغانستان، للاشتباه في تورطه في هجمات 11 سبتمبر، على الرغم من أنه عاش في ألمانيا طوال السنوات العشر الماضية.", "privacy-matters-overlay-card-3-p-3": "يتم القبض على الأشخاص العاديين بسبب ما يشاركونه عبر الإنترنت، حتى عبر حساباتهم \"المجهولة\"، وحتى في البلدان الديمقراطية.", - "simplex-unique-overlay-card-1-p-1": "على عكس أنظمة المُراسلة الأخرى، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين. لا يعتمد على أرقام الهواتف أو العناوين المستندة إلى النطاقات (مثل البريد الإلكتروني أو XMPP)، أسماء المستخدمين، المفاتيح العامة أو حتى الأرقام العشوائية لتحديد مستخدميها — لا نعرف عدد الأشخاص الذين يستخدمون خوادم SimpleX الخاصة بنا.", - "simplex-unique-overlay-card-1-p-2": "لتسليم الرسائل، يستخدم SimpleX العناوين المزدوجة المجهولة لقوائم انتظار الرسائل أحادية الاتجاه، منفصلة عن الرسائل المُستلمة والمُرسلة، عادةً عبر خوادم مختلفة. إن استخدام SimpleX يشبه امتلاك بريد إلكتروني أو هاتف “مؤقت” مختلف لكل جهة اتصال، ولا توجد متاعب في إدارتها.", + "simplex-unique-overlay-card-1-p-1": "على عكس شبكات المُراسلة الأخرى، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين. لا يعتمد على أرقام الهواتف أو العناوين المستندة إلى النطاقات (مثل البريد الإلكتروني أو XMPP)، أسماء المستخدمين، المفاتيح العامة أو حتى الأرقام العشوائية لتحديد مستخدميها — مُشغلي خادم SimpleX لا يعرفون عدد الأشخاص الذين يستخدمون خوادمهم.", + "simplex-unique-overlay-card-1-p-2": "لتسليم الرسائل، يستخدم SimpleX العناوين المزدوجة المجهولة لقوائم انتظار الرسائل أحادية الاتجاه، منفصلة عن الرسائل المُستلمة والمُرسلة، عادةً عبر خوادم مختلفة.", "simplex-unique-overlay-card-3-p-1": "يخزن SimpleX Chat جميع بيانات المستخدم على أجهزة العميل فقط باستخدام تنسيق قاعدة بيانات محمولة مُعمَّاة يمكّن تصديرها ونقلها إلى أي جهاز مدعوم.", "simplex-unique-overlay-card-3-p-3": "على عكس خوادم الشبكات الاتحادية (البريد الإلكتروني أو XMPP أو Matrix)، لا تقوم خوادم SimpleX بتخزين حسابات المستخدمين، فهي تقوم فقط بترحيل الرسائل، مما يحمي خصوصية كلا الطرفين.", - "simplex-unique-overlay-card-4-p-1": "يمكنك استخدام SimpleX مع الخوادم الخاصة بك والاستمرار في التواصل مع الأشخاص الذين يستخدمون الخوادم المهيأة مسبقًا التي نقدمها.", - "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير لمنصة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", + "simplex-unique-overlay-card-4-p-1": "يمكنك استخدام SimpleX مع خوادمك والاستمرار في التواصل مع الأشخاص الذين يستخدمون الخوادم المُهيأة مسبقًا في التطبيقات.", + "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير شبكة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", "donate-here-to-help-us": "تبرّع هنا لمساعدتنا", "sign-up-to-receive-our-updates": "اشترك للحصول على آخر مستجداتنا", "enter-your-email-address": "أدخل عنوان بريدك الإلكتروني", "get-simplex": "احصل على تطبيق سطح المكتب SimpleX", - "why-simplex-is": "لماذا SimpleX", - "unique": "فريد من نوعه", + "why-simplex-is-unique": "لماذا SimpleX فريد من نوعه", "learn-more": "اقرأ أكثر", "more-info": "معلومات أكثر", "hide-info": "معلومات أقل", @@ -153,16 +152,16 @@ "no-decentralized": "لا - لامركزي", "no-federated": "لا - اتِحاديّ", "comparison-section-list-point-2": "العناوين تعتمد على الـDNS", - "comparison-section-list-point-3": "المفتاح العام أو معرف آخر فريد وعام", + "comparison-section-list-point-3": "المفتاح العام أو معرّف آخر فريد وعام", "comparison-section-list-point-7": "شبكات P2P إما لديها سلطة مركزية أو أن الشبكة كلها يمكن عرضة للخطر", "see-here": "اقرأ هنا", "no-secure": "لا - آمن", "comparison-section-list-point-5": "لا يحمي خصوصية البيانات الوصفية للمستخدمين", - "comparison-section-list-point-6": "على الرغم من أن الـP2P موزعة، إلا أنها ليست اتِحاديَّة - يعملون كشبكة واحدة", + "comparison-section-list-point-6": "على الرغم من أن الـP2P موزعة، إلا أنها ليست اتِحاديَّة — يعملون كشبكة واحدة", "comparison-section-list-point-1": "عادة ما يكون مكوناً من رقم الهاتف، أو اسم المستخدم في بعض الأحيان", "comparison-section-list-point-4": "إذا خوادم المشغّل مُخترقة. تحقق من رمز الأمان في Signal وبعض التطبيقات الأخرى للتخفيف منه", "simplex-unique-card-3-p-1": "يخزن SimpleX جميع بيانات المستخدم على الأجهزة العميلة بتنسيق قاعدة بيانات محمولة مُعمَّاة — يمكّن نقلها إلى جهاز آخر.", - "simplex-unique-card-4-p-1": "شبكة SimpleX لا مركزية بالكامل ومستقلة عن أي عملة مُعمَّاة أو أي منصة أخرى، بخلاف الإنترنت.", + "simplex-unique-card-4-p-1": "شبكة SimpleX لا مركزية بالكامل ومستقلة عن أي عملة مُعمَّاة أو أي شبكة أخرى، بخلاف الإنترنت.", "simplex-unique-card-4-p-2": "يمكنك استخدام SimpleX مع خوادمك الخاصة أو مع الخوادم التي نوفرها — ولا يزال الاتصال ممكن بأي مستخدم.", "join": "انضم إلى", "we-invite-you-to-join-the-conversation": "نحن ندعوك للانضمام إلى المُحادثة", @@ -179,7 +178,7 @@ "use-this-command": "استخدم هذا الأمر:", "see-simplex-chat": "انظر SimpleX Chat", "github-repository": "مستودع Github", - "the-instructions--source-code": "التعليمات حول كيفية تنزيله أو تجميعه من التعليمات البرمجية المصدر.", + "the-instructions--source-code": "للتعليمات حول كيفية تنزيله أو تجميعه من التعليمات البرمجية المصدر.", "if-you-already-installed-simplex-chat-for-the-terminal": "إذا قمت بالفعل بتثبيت SimpleX Chat للوحدة الطرفية", "simplex-chat-for-the-terminal": "SimpleX Chat للوحدة الطرفية", "privacy-matters-section-header": "لماذا الخصوصية مهمة", @@ -190,8 +189,8 @@ "simplex-network-3-header": "شبكة SimpleX", "protocol-1-text": "Signal، منصات كبيرة", "protocol-2-text": "XMPP ،Matrix", - "simplex-unique-card-1-p-2": "بخلاف أي نظام مُراسلة آخر، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين — ولا حتى أرقام عشوائية.", - "simplex-unique-card-2-p-1": "نظرًا لعدم وجود معرف أو عنوان ثابت على منصة SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", + "simplex-unique-card-1-p-2": "على عكس أي شبكة مُراسلة آخر، لا يحتوي SimpleX على معرّفات مخصّصة للمستخدمين — ولا حتى أرقام عشوائية.", + "simplex-unique-card-2-p-1": "نظرًا لعدم وجود معرّف أو عنوان ثابت على شبكة SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", "simplex-unique-card-3-p-2": "يتم الاحتفاظ بالرسائل المُعمَّاة بين الطرفين مؤقتًا على خوادم ترحيل SimpleX حتى يتم استلامها، ثُمَّ تُحذف نهائيًا.", "tap-the-connect-button-in-the-app": "اضغط على زر \"اتصال\" في التطبيق", "scan-the-qr-code-with-the-simplex-chat-app": "امسح رمز QR باستخدام تطبيق SimpleX Chat", @@ -209,7 +208,7 @@ "simplex-network-2-desc": "لا تقوم خوادم الترحيل SimpleX بتخزين ملفات تعريف المستخدمين وجهات الاتصال والرسائل التي تم تسليمها، ولا تتصل ببعضها البعض، ولا يوجد دليل خوادم.", "comparison-point-1-text": "يتطلب هوية عالمية", "protocol-3-text": "بروتوكولات P2P", - "simplex-unique-overlay-card-4-p-2": "تستخدم منصة SimpleX بروتوكول مفتوح وتوفر SDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي يمكنك إنشاؤها.", + "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة SimpleX بروتوكول مفتوح وتوفر SDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", "guide-dropdown-1": "بداية سريعة", "guide-dropdown-2": "إرسال الرسائل", "guide-dropdown-3": "مجموعات سرية", @@ -228,7 +227,7 @@ "menu": "قائمة", "on-this-page": "على هذه الصفحة", "back-to-top": "عد إلى الأعلى", - "docs-dropdown-1": "منصة SimpleX", + "docs-dropdown-1": "شبكة SimpleX", "guide": "الدليل", "docs-dropdown-2": "الوصول إلى ملفات اندرويد", "docs-dropdown-3": "الوصول إلى قاعدة بيانات الدردشة", @@ -245,7 +244,7 @@ "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يُرجى تصدير قاعدة بيانات الدردشة وإعادة تثبيت التطبيق.", "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تعمية بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق", "hero-overlay-3-title": "التقييمات الأمنية", - "hero-overlay-card-3-p-2": "قامت Trail of Bits بمراجعة مكونات التشفير والشبكات الخاصة بمنصة SimpleX في نوفمبر 2022. اقرأ المزيد في الإعلان.", + "hero-overlay-card-3-p-2": "قامت Trail of Bits بمراجعة مكونات التشفير والشبكات الخاصة بشبكة SimpleX في نوفمبر 2022. اقرأ المزيد في الإعلان.", "jobs": "انضم للفريق", "hero-overlay-3-textlink": "التقييمات الأمنية", "hero-overlay-card-3-p-1": "Trail of Bits هي شركة رائدة في مجال الاستشارات الأمنية والتكنولوجية، ومن بين عملائها شركات التكنولوجيا الكبرى والوكالات الحكومية ومشاريع blockchain الكبرى.", diff --git a/website/langs/bg.json b/website/langs/bg.json index 963f850e53..21fb93c2cb 100644 --- a/website/langs/bg.json +++ b/website/langs/bg.json @@ -21,7 +21,7 @@ "smp-protocol": "СМП Протокол", "chat-protocol": "Чат протокол", "donate": "Дарете", - "copyright-label": "© 2020-2024 SimpleX | Проект с отворен код", + "copyright-label": "© 2020-2025 SimpleX | Проект с отворен код", "simplex-chat-protocol": "SimpleX Чат протокол", "terminal-cli": "Системна конзола", "terms-and-privacy-policy": "Условия и политика за поверителност", diff --git a/website/langs/cs.json b/website/langs/cs.json index 0e19fdbfe4..47ab2c99d0 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -14,7 +14,7 @@ "features": "Funkce", "why-simplex": "Proč SimpleX", "simplex-privacy": "SimpleX soukromí", - "simplex-explained": "Simplex vysvětlení", + "simplex-explained": "SimpleX vysvětlení", "simplex-explained-tab-1-text": "1. Co vidí uživatel", "simplex-explained-tab-2-text": "2. Jak to funguje", "simplex-explained-tab-3-text": "3. Co vidí servery", @@ -25,7 +25,7 @@ "smp-protocol": "SMP protokol", "chat-protocol": "Chat protokol", "donate": "Darovat", - "copyright-label": "© 2020-2024 SimpleX | Projekt s otevřeným zdrojovým kódem", + "copyright-label": "© 2020-2025 SimpleX | Projekt s otevřeným zdrojovým kódem", "simplex-chat-protocol": "SimpleX Chat protokol", "terminal-cli": "Terminálové rozhraní příkazového řádku", "terms-and-privacy-policy": "Ochrana soukromí", @@ -115,8 +115,7 @@ "sign-up-to-receive-our-updates": "Přihlaste se k odběru novinek", "enter-your-email-address": "vložte svou e-mailovou adresu", "get-simplex": "Získat SimpleX desktop app", - "why-simplex-is": "Proč je SimpleX", - "unique": "jedinečný", + "why-simplex-is-unique": "Proč je SimpleX jedinečný", "learn-more": "Další informace", "more-info": "Více informací", "hide-info": "Skrýt informace", @@ -180,7 +179,7 @@ "privacy-matters-overlay-card-1-p-2": "Internetoví prodejci vědí, že lidé s nižšími příjmy častěji provádějí urgentní nákupy, takže mohou účtovat vyšší ceny nebo odebírat slevy.", "privacy-matters-overlay-card-1-p-4": "Platforma SimpleX chrání soukromí vašich připojení lépe než jakákoli jiná alternativa a plně zabraňuje tomu, aby byl váš sociální graf dostupný všem společnostem nebo organizacím. I když lidé používají servery poskytované SimpleX Chat, neznáme počet uživatelů ani jejich připojení.", "privacy-matters-overlay-card-2-p-2": "Chcete-li být objektivní a činit nezávislá rozhodnutí, musíte mít svůj informační prostor pod kontrolou. Je to možné pouze v případě, že používáte soukromou komunikační platformu, která nemá přístup k vašemu sociálnímu grafu.", - "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery. Používání SimpleX je jako mít jinou “ vypalovačku” e-mail nebo telefon pro každý kontakt a žádné potíže s jejich správou.", + "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery.", "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", "simplex-unique-overlay-card-1-p-1": "Na rozdíl od jiných platforem pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům. Nespoléhá se na telefonní čísla, adresy založené na doméně (jako je e-mail nebo XMPP), uživatelská jména, veřejné klíče nebo dokonce náhodná čísla k identifikaci svých uživatelů —. Nevíme', kolik lidí používá naše servery SimpleX.", "invitation-hero-header": "Byl vám zaslán odkaz pro připojení na SimpleX Chat", @@ -209,7 +208,7 @@ "if-you-already-installed-simplex-chat-for-the-terminal": "Pokud jste již nainstalovali SimpleX Chat pro terminál", "if-you-already-installed": "Pokud jste již nainstalovali", "simplex-network-1-desc": "Všechny zprávy se odesílají přes servery, což zajišťuje lepší soukromí metadat a spolehlivé asynchronní doručování zpráv, přičemž se zamezuje mnoha", - "simplex-private-card-1-point-1": "Protokol s dvojitou západkou —
zprávy OTR s dokonalým dopředným utajením a obnovení po vloupání.", + "simplex-private-card-1-point-1": "Double-ratchet protokol —
OTR messaging s dokonalým dopředným utajením a obnovou po vloupání.", "guide-dropdown-1": "Rychlý start", "guide-dropdown-2": "Odesílání zpráv", "guide-dropdown-3": "Tajné skupiny", @@ -233,8 +232,8 @@ "back-to-top": "Zpět nahoru", "newer-version-of-eng-msg": "V angličtině je novější verze této stránky.", "glossary": "Rejstřík", - "hero-overlay-3-title": "Posouzení bezpečnosti", - "hero-overlay-3-textlink": "Posouzení bezpečnosti", + "hero-overlay-3-title": "Hodnocení zabezpečení", + "hero-overlay-3-textlink": "Hodnocení zabezpečení", "hero-overlay-card-3-p-1": "Trail of Bits je přední bezpečnostní a technologické poradenství, jejichž klienti zahrnují velké technologické firmy, vládní agentury a významné blockchainové projekty.", "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", @@ -254,5 +253,7 @@ "docs-dropdown-9": "Ke stažení", "docs-dropdown-10": "Transparentnost", "docs-dropdown-11": "FAQ (často kladené dotazy)", - "docs-dropdown-12": "Bezpečnost" + "docs-dropdown-12": "Bezpečnost", + "hero-overlay-card-3-p-3": "Trail of Bits přezkoumala kryptografický design síťových protokolů SimpleX v červenci 2024. Přečíst více.", + "docs-dropdown-14": "SimpleX pro podnikání" } diff --git a/website/langs/de.json b/website/langs/de.json index 3b1e9d34e8..3e5ef9fbc4 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -15,13 +15,13 @@ "simplex-explained-tab-1-p-1": "Sie können Kontakte und Gruppen erstellen und haben Zwei-Wege-Kommunikation wie in jedem anderen Messenger.", "simplex-explained-tab-1-p-2": "Wie funktioniert das mit den unidirektionalen Warteschlangen und ohne Profilkennungen?", "simplex-explained-tab-2-p-1": "Für jede Verbindung nutzen Sie zwei separate Nachrichten-Warteschlangen, um die Nachrichten über verschiedene Server zu senden und zu empfangen.", - "simplex-explained-tab-2-p-2": "Die Server leiten Nachrichten immer nur in eine Richtung weiter, ohne den vollständigen Verlauf der Nutzer-Unterhaltung oder seiner Verbindungen zu kennen.", + "simplex-explained-tab-2-p-2": "Die Server leiten Nachrichten immer nur in eine Richtung weiter, ohne den vollständigen Verlauf der Nutzer-Unterhaltungen oder seiner Verbindungen zu kennen.", "simplex-explained-tab-3-p-1": "Die Server nutzen für jede Warteschlange separate, anonyme Anmeldeinformationen und wissen nicht welchem Nutzer diese gehören.", "simplex-explained-tab-3-p-2": "Durch die Verwendung von Tor-Zugangsservern können Nutzer ihre Metadaten-Privatsphäre weiter verbessern und Korellationen von IP-Adressen verhindern.", "smp-protocol": "SMP-Protokoll", "chat-bot-example": "Beispiel für einen Chatbot", "donate": "Spenden", - "copyright-label": "© 2020-2024 SimpleX | Open-Source-Projekt", + "copyright-label": "© 2020-2025 SimpleX | Open-Source-Projekt", "chat-protocol": "Chat-Protokoll", "simplex-chat-protocol": "SimpleX-Chat-Protokoll", "terminal-cli": "Terminal-Kommandozeilen-Schnittstelle", @@ -54,7 +54,7 @@ "simplex-private-6-title": "Out-of-Band-
Schlüsselaustausch", "simplex-private-9-title": "Unidirektionale
Nachrichten-Warteschlangen", "simplex-private-10-title": "Temporäre, anonyme paarweise Kennungen", - "simplex-private-card-1-point-1": "Double-Ratchet-Protokoll —
Off-the-Record-Nachrichten mit Perfect Forward Secrecy und Einbruchsresistenz.", + "simplex-private-card-1-point-1": "Double-Ratchet-Protokoll —
Off-the-Record-Nachrichten mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung.", "simplex-private-card-1-point-2": "NaCL-Kryptobox in jeder Warteschlange, um eine Korrelation des Datenverkehrs zwischen Nachrichtenwarteschlangen zu verhindern, falls TLS kompromittiert wurde.", "simplex-private-card-3-point-1": "Für Client-Server-Verbindungen wird nur TLS 1.2/1.3 mit starken Algorithmen verwendet.", "simplex-private-card-2-point-1": "Zusätzliche Server-Verschlüsselungs-Schicht für die Zustellung an den Empfänger, um eine Korrelation zwischen empfangenen und gesendeten Server-Daten zu vermeiden, falls TLS kompromittiert wurde.", @@ -64,7 +64,7 @@ "simplex-private-card-4-point-2": "Um SimpleX per Tor zu nutzen, installieren Sie unter Android bitte die Orbot-App und aktivieren Sie den SOCKS5-Proxy oder unter iOS per VPN.", "simplex-private-card-5-point-1": "SimpleX nutzt Inhalte-Auffüllung für jede Verschlüsselungs-Schicht, um Angriffe auf die Nachrichtengröße zu vereiteln.", "simplex-private-card-5-point-2": "Erzeugt Nachrichten mit unterschiedlichen Größen, die für Server und Netzwerk-Beobachter identisch aussehen.", - "simplex-private-card-6-point-1": "Viele Kommunikations-Plattformen sind für MITM-Angriffe durch Server oder Netzwerk-Anbieter anfällig.", + "simplex-private-card-6-point-1": "Viele Kommunikations-Netzwerke sind für MITM-Angriffe durch Server oder Netzwerk-Anbieter anfällig.", "simplex-private-card-9-point-1": "Jede Nachrichten-Warteschlange leitet Nachrichten mit unterschiedlichen Sende- und Empfängeradressen jeweils nur in einer Richtung weiter.", "simplex-private-card-9-point-2": "Verglichen mit traditionellen Nachrichten-Brokern, werden mögliche Angriffs-Vektoren und vorhandene Metadaten reduziert.", "simplex-private-card-10-point-1": "SimpleX nutzt für jeden Nutzer-Kontakt oder jedes Gruppenmitglied eigene temporäre, anonyme und paarweise Adressen und Berechtigungsnachweise.", @@ -95,29 +95,29 @@ "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX-Whitepaper.", "hero-overlay-card-2-p-2": "Sie können diese Informationen mit bestehenden öffentlichen sozialen Netzwerken korrelieren und damit wahre Identitäten herausfinden.", "hero-overlay-card-2-p-3": "Wenn Sie sich mit zwei unterschiedlichen Kontakten über dasselbe Profil unterhalten, können sie, selbst bei sehr auf Privatsphäre bedachten Apps, die Tor-v3-Dienste nutzen, feststellen, dass diese Kontakte mit derselben Person verbunden sind.", - "hero-overlay-card-1-p-2": "Um Nachrichten auszuliefern, nutzt SimpleX statt Benutzerkennungen wie auf allen anderen Plattformen temporäre, anonyme und paarweise Kennungen für Nachrichten-Warteschlangen, die für jede Ihrer Verbindungen unterschiedlich sind — Es gibt keinerlei Langzeit-Kennungen.", + "hero-overlay-card-1-p-2": "Um Nachrichten auszuliefern, nutzt SimpleX statt Benutzerkennungen wie alle anderen Netzwerke nur temporäre, anonyme und paarweise Kennungen für Nachrichten-Warteschlangen, die für jede Ihrer Verbindungen unterschiedlich sind — es gibt keinerlei Langzeit-Kennungen.", "hero-overlay-card-1-p-4": "Dieses Design verhindert schon auf der Applikations-Ebene Datenlecks für jegliche Benutzer'Metadaten. Sie können sich über Tor mit Nachrichten-Servern verbinden, um Ihre Privatsphäre weiter zu verbessern und die von Ihnen genutzte IP-Adresse zu schützen.", "hero-overlay-card-2-p-1": "Wenn Nutzer dauerhafte Identitäten besitzen, selbst wenn diese eine Zufallsnummer, wie eine Sitzungs-ID, ist, besteht ein Risiko, das Provider oder Angreifer feststellen können, wie Nutzer miteinander verbunden sind und wie viele Nachrichten sie versenden.", "hero-overlay-card-2-p-4": "SimpleX schützt gegen solche Angriffe, weil es vom Design her keinerlei Benutzerkennungen besitzt. Und Sie haben sogar unterschiedliche Anzeigenamen für jeden Kontakt und vermeiden jegliche geteilte Daten zwischen diesen, wenn Sie den Inkognito-Modus nutzen.", "simplex-network-overlay-card-1-p-1": "Peer-to-Peer-Nachrichten-Protokolle und -Applikationen haben verschiedene Probleme, die diese weniger vertrauenswürdig, die Analyse wesentlich komplexer und anfälliger gegen verschiedene Arten von Angriffen, als bei SimpleX machen.", "simplex-network-overlay-card-1-li-2": "Das SimpleX Design hat, im Gegensatz zu den meisten P2P-Netzwerken, keinerlei globalen Benutzerkennungen, auch keine temporären. Es nutzt ausschließlich temporäre paarweise Kennungen, die bessere Anonymität und Metadaten-Schutz bieten.", - "simplex-network-overlay-card-1-li-4": "P2P-Implementierungen können durch Internetanbieter blockiert werden, wie beispielweise BitTorrent). SimpleX ist transportunabhängig – es kann über Standard-Web-Protokolle, wie beispielsweise WebSockets, arbeiten.", + "simplex-network-overlay-card-1-li-4": "——P2P-Implementierungen können durch Internetanbieter blockiert werden, wie beispielweise BitTorrent). SimpleX ist transportunabhängig — es kann über Standard-Web-Protokolle, wie beispielsweise WebSockets, arbeiten.", "simplex-network-overlay-card-1-li-6": "P2P-Netzwerke können anfällig für DRDoS-Angriffe sein, wenn die Clients den Datenverkehr erneut senden und verstärken können, was zu einem netzwerkweiten Denial-of-Service führt. SimpleX-Clients leiten nur Datenverkehr von bekannten Verbindungen weiter und können von einem Angreifer nicht dazu verwendet werden, den Datenverkehr im gesamten Netzwerk zu verstärken.", "privacy-matters-overlay-card-1-p-1": "Viele große Unternehmen nutzen Informationen, mit wem Sie in Verbindung stehen, um Ihr Einkommen zu schätzen, Ihnen Produkte zu verkaufen, die Sie nicht wirklich benötigen und um die Preise zu bestimmen.", "privacy-matters-overlay-card-1-p-2": "Online-Händler wissen, dass Menschen mit geringerem Einkommen eher dringende Einkäufe tätigen, sodass sie möglicherweise höhere Preise verlangen können oder Rabatte streichen.", "privacy-matters-overlay-card-1-p-3": "Einige Finanz- und Versicherungsunternehmen verwenden soziale Graphen, um Zinssätze und Prämien zu ermitteln. Menschen mit niedrigerem Einkommen zahlen so häufig mehr — dies ist als \"Armutsprämie\" bekannt.", - "privacy-matters-overlay-card-2-p-2": "Um objektiv zu sein und unabhängige Entscheidungen treffen zu können, müssen Sie die Kontrolle über Ihren Informationsraum haben. Dies ist nur möglich, wenn Sie eine private Kommunikationsplattform verwenden, die keinen Zugriff auf Ihren sozialen Graphen hat.", - "privacy-matters-overlay-card-2-p-3": "SimpleX ist die erste Plattform, die per Design keinerlei Benutzerkennungen hat und auf diese Weise Ihren Verbindungsgraphen besser schützt als jede andere bekannte Alternative.", + "privacy-matters-overlay-card-2-p-2": "Um objektiv zu sein und unabhängige Entscheidungen treffen zu können, müssen Sie die Kontrolle über Ihren Informationsraum haben. Dies ist nur möglich, wenn Sie ein privates Kommunikations-Netzwerk verwenden, welches keinen Zugriff auf Ihren sozialen Graphen hat.", + "privacy-matters-overlay-card-2-p-3": "SimpleX ist das erste Netzwerk, welches per Design keinerlei Benutzerkennungen hat und auf diese Weise Ihren Verbindungsgraphen besser schützt als jede andere bekannte Alternative.", "privacy-matters-overlay-card-3-p-1": "Jede Person sollte sich um ihre Privatsphäre und die Sicherheit ihrer Kommunikation kümmern — Harmlose Gespräche könnten Sie in Gefahr bringen, selbst wenn Sie nichts zu verbergen haben.", "privacy-matters-overlay-card-3-p-4": "Es reicht nicht aus, einfach einen Ende-zu-Ende-verschlüsselten Messenger zu verwenden. Wir alle sollten den Messenger verwenden, der die Privatsphäre unserer persönlichen Netzwerke schützt, mit welchen wir verbunden sind.", - "simplex-unique-overlay-card-1-p-3": "Dieses Design schützt die Privatsphäre von Ihnen und der Personen, mit denen Sie kommunizieren, und verbirgt die Verbindungen vor den SimpleX-Plattform-Servern und möglichen Beobachtern. Um Ihre IP-Adresse vor den Servern zu verbergen, können Sie sich per Tor mit den SimpleX-Servern verbinden.", - "simplex-unique-overlay-card-2-p-1": "Da Sie auf der SimpleX-Plattform keine Kennungen haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder eines Links weiter.", + "simplex-unique-overlay-card-1-p-3": "Dieses Design schützt die Privatsphäre von Ihnen und der Nutzer, mit denen Sie kommunizieren, und verbirgt die Verbindungen vor den SimpleX-Netzwerk-Servern und möglichen Beobachtern. Um Ihre IP-Adresse vor den Servern zu verbergen, können Sie sich per Tor mit den SimpleX-Servern verbinden.", + "simplex-unique-overlay-card-2-p-1": "Da Sie im SimpleX-Netzwerk keine Kennungen haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder eines Links weiter.", "simplex-unique-overlay-card-3-p-2": "Die Ende-zu-Ende-verschlüsselten Nachrichten werden vorübergehend auf SimpleX-Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", "simplex-unique-overlay-card-3-p-3": "Im Gegensatz zu föderierten Netzwerkservern (wie z.B. Mail, XMPP oder Matrix) speichern die SimpleX-Server keine Benutzerkonten, sondern leiten Nachrichten nur weiter, so dass die Privatsphäre der beteiligten Parteien geschützt ist.", - "simplex-unique-overlay-card-4-p-1": "Sie können SimpleX mit Ihren eigenen Servern verwenden und trotzdem mit Personen kommunizieren, welche die von uns bereitgestellten und vorkonfigurierten Server verwenden.", - "simplex-unique-card-1-p-1": "SimpleX schützt die Privatsphäre Ihres Profils, Ihrer Kontakte und Metadaten und verbirgt sie vor den SimpleX-Plattform-Servern und allen Beobachtern.", - "simplex-unique-card-1-p-2": "Im Gegensatz zu allen anderen bestehenden Messaging-Plattformen werden den Nutzern von SimpleX keine Kennungen zugewiesen — nicht einmal Zufallszahlen.", - "simplex-unique-card-2-p-1": "Da Sie keine Kennung oder feste Adresse auf der SimpleX-Plattform haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder Links weiter.", + "simplex-unique-overlay-card-4-p-1": "Sie können SimpleX mit Ihren eigenen Servern verwenden und trotzdem mit Nutzern kommunizieren, welche die vorkonfigurierten Server der App verwenden.", + "simplex-unique-card-1-p-1": "SimpleX schützt die Privatsphäre Ihres Profils, Ihrer Kontakte und Metadaten und verbirgt sie vor den SimpleX-Netzwerk-Servern und weiteren möglichen Beobachtern.", + "simplex-unique-card-1-p-2": "Im Gegensatz zu allen anderen bestehenden Messaging-Netzwerken werden den Nutzern von SimpleX keine Kennungen zugewiesen — nicht einmal Zufallszahlen.", + "simplex-unique-card-2-p-1": "Da Sie keine Kennung oder feste Adresse im SimpleX-Netzwerk haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder Links weiter.", "simplex-unique-card-3-p-1": "SimpleX speichert Benutzerdaten nur auf den Endgeräten und das in einem portablen, verschlüsselten Datenbankformat — welches auf ein anderes Gerät übertragen werden kann.", "simplex-unique-card-3-p-2": "Die Ende-zu-Ende-verschlüsselten Nachrichten werden vorübergehend auf SimpleX-Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", "join": "Nutzen Sie", @@ -142,7 +142,7 @@ "use-this-command": "Benutzen Sie dieses Kommando:", "see-simplex-chat": "Siehe SimpleX-Chat", "github-repository": "GitHub-Repository", - "the-instructions--source-code": "Die Anleitung, wie Sie es herunterladen und aus dem Quellcode kompilieren.", + "the-instructions--source-code": "Für die Anleitungen, wie Sie es herunterladen oder aus dem Quellcode kompilieren.", "if-you-already-installed": "Wenn Sie es schon installiert haben", "simplex-chat-for-the-terminal": "SimpleX Chat für das Terminal", "privacy-matters-section-header": "Warum es auf Privatsphäre ankommt", @@ -171,15 +171,14 @@ "comparison-section-list-point-2": "DNS-basierte Adressen", "comparison-section-list-point-3": "Öffentlicher Schlüssel oder eine andere weltweit eindeutige ID", "comparison-section-list-point-4": "Wenn die Server des Betreibers kompromittiert werden. In Signal und weiteren Apps kann der Sicherheitscode überprüft werden, um dies zu entschärfen", - "comparison-section-list-point-6": "P2P sind zwar verteilt, aber nicht föderiert - sie arbeiten als ein einziges Netzwerk", + "comparison-section-list-point-6": "P2P sind zwar verteilt, aber nicht föderiert — sie arbeiten als ein einziges Netzwerk", "comparison-section-list-point-7": "P2P-Netzwerke haben entweder eine zentrale Verwaltung oder das gesamte Netzwerk kann kompromittiert werden", "see-here": "Siehe hier", - "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Plattformen außer dem Internet.", - "unique": "einmalig", + "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Netzwerken außer dem Internet.", "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", - "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Plattformen weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder sogar Zufallszahlen, um seine Benutzer zu identifizieren — Wir wissen nicht, wie viele Personen unsere SimpleX-Server verwenden.", - "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden. Die Nutzung von SimpleX entspricht der Nutzung von unterschiedlichen Mailservern oder Telefonen für jeden einzelnen Kontakt und vermeidet dabei eine mühsame Verwaltung.", + "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Netzwerken weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder Zufallszahlen, um seine Benutzer zu identifizieren — selbst SimpleX-Server-Betreiber wissen nicht, wie viele Personen deren Server verwenden.", + "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden.", "simplex-network-overlay-card-1-li-5": "Alle bekannten P2P-Netzwerke können anfällig für Sybil-Angriffe sein, da jeder Knoten ermittelbar ist und das Netzwerk als Ganzes funktioniert. Bekannte Maßnahmen zur Verhinderung erfordern entweder eine zentralisierte Komponente oder einen teuren Ausführungsnachweis. Das SimpleX-Netzwerk bietet keine Ermittlung der Server, ist fragmentiert und arbeitet mit mehreren isolierten Subnetzwerken, wodurch netzwerkweite Angriffe unmöglich werden.", "simplex-network-overlay-card-1-li-3": "P2P löst nicht das Problem des MITM-Angriffs und die meisten bestehenden Implementierungen nutzen für den initialen Schlüsselaustausch keine Out-of-Band-Nachrichten. Im Gegensatz hierzu nutzt SimpleX für den initialen Schlüsselaustausch Out-of-Band-Nachrichten oder zum Teil schon bestehende sichere und vertrauenswürdige Verbindungen.", "tap-the-connect-button-in-the-app": "Drücken Sie die „Verbinden“-Taste in der Applikation", @@ -197,16 +196,16 @@ "comparison-section-list-point-5": "Die Privatsphäre-Metadaten des Nutzers werden nicht geschützt", "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von DHT, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über O(log N)-Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", - "simplex-unique-overlay-card-4-p-3": "Wenn Sie darüber nachdenken, für die SimpleX-Plattform entwickeln zu wollen, z.B. einen Chatbot für SimpleX-App-Nutzer oder die Integration der SimpleX-Chat-Bibliothek in Ihre mobilen Apps, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", - "privacy-matters-overlay-card-1-p-4": "Die SimpleX-Plattform schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn die Anwender SimpleX-Chat-Server verwenden, kennen wir die Anzahl der Benutzer oder ihre Verbindungen nicht.", + "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX-App-Nutzer zu entwickeln oder die Integration der SimpleX-Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", + "privacy-matters-overlay-card-1-p-4": "Das SimpleX-Netzwerk schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn Anwender die in der SimpleX-Chat-App vorkonfigurierten Server verwenden, kennen die Server-Betreiber die Anzahl der Benutzer oder deren Verbindungen nicht.", "contact-hero-header": "Sie haben eine Adresse zur Verbindung mit SimpleX Chat erhalten", "invitation-hero-header": "Sie haben einen Einmal-Link zur Verbindung mit SimpleX Chat erhalten", "privacy-matters-overlay-card-3-p-3": "Normale Menschen werden für das, was sie online teilen, sogar unter Nutzung ihrer „anonymen“ Konten, selbst in demokratischen Ländern verhaftet.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat speichert alle Benutzerdaten ausschließlich auf den Endgeräten in einem portablen und verschlüsselten Datenbankformat, welches exportiert und auf jedes unterstützte Gerät übertragen werden kann.", "simplex-unique-overlay-card-2-p-2": "Auch wenn die optionale Benutzeradresse zum Versenden von Spam-Kontaktanfragen verwendet werden kann, können Sie sie ändern oder ganz löschen, ohne dass Ihre Verbindungen verloren gehen.", - "simplex-unique-overlay-card-4-p-2": "Die SimpleX-Plattform verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an, welches die Erstellung von Diensten ermöglicht, mit denen Nutzer über SimpleX-Chat interagieren können — Wir sind gespannt, welche SimpleX-Dienste Sie erstellen können.", + "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX-Chat-Apps interagieren können — wir sind gespannt, welche SimpleX-Dienste Sie entwickeln werden.", "simplex-unique-card-4-p-2": "Sie können SimpleX mit Ihren eigenen Servern oder mit den von uns zur Verfügung gestellten Servern verwenden — und sich trotzdem mit jedem Benutzer verbinden.", - "why-simplex-is": "Warum ist SimpleX", + "why-simplex-is-unique": "Warum ist SimpleX einmalig", "contact-hero-p-1": "Die öffentlichen Schlüssel und die Adresse der Nachrichtenwarteschlange in diesem Link werden NICHT über das Netzwerk gesendet, wenn Sie diese Seite aufrufen — sie sind in dem Hash-Fragment der Link-URL enthalten.", "if-you-already-installed-simplex-chat-for-the-terminal": "Wenn Sie SimpleX schon für das Terminal installiert haben", "simplex-network-3-desc": "Die Server stellen unidirektionale Warteschlangen zur Verfügung, um die Benutzer miteinander zu verbinden. Sie haben aber keinen Einblick in den Verbindungs-Graphen des Netzwerks — Diesen haben nur die Benutzer selbst.", @@ -219,7 +218,7 @@ "guide-dropdown-8": "App-Einstellungen", "guide-dropdown-9": "Verbindungen herstellen", "guide": "Leitfaden", - "docs-dropdown-1": "SimpleX-Plattform", + "docs-dropdown-1": "SimpleX-Netzwerk", "docs-dropdown-2": "Zugriff auf Android-Dateien", "docs-dropdown-3": "Zugriff auf die Chat-Datenbank", "docs-dropdown-4": "Den SMP-Server hosten", diff --git a/website/langs/en.json b/website/langs/en.json index e57b3375de..f9691e2594 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -7,21 +7,21 @@ "why-simplex": "Why SimpleX", "simplex-privacy": "SimpleX privacy", "simplex-network": "SimpleX network", - "simplex-explained": "Simplex explained", + "simplex-explained": "SimpleX explained", "simplex-explained-tab-1-text": "1. What users experience", "simplex-explained-tab-2-text": "2. How does it work", "simplex-explained-tab-3-text": "3. What servers see", "simplex-explained-tab-1-p-1": "You can create contacts and groups, and have two-way conversations, as in any other messenger.", "simplex-explained-tab-1-p-2": "How can it work with unidirectional queues and without user profile identifiers?", "simplex-explained-tab-2-p-1": "For each connection you use two separate messaging queues to send and receive messages via different servers.", - "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversation or connections.", + "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversations or connections.", "simplex-explained-tab-3-p-1": "The servers have separate anonymous credentials for each queue, and do not know which users they belong to.", "simplex-explained-tab-3-p-2": "Users can further improve metadata privacy by using Tor to access servers, preventing corellation by IP address.", "chat-bot-example": "Chat bot example", "smp-protocol": "SMP protocol", "chat-protocol": "Chat protocol", "donate": "Donate", - "copyright-label": "© 2020-2024 SimpleX | Open-Source Project", + "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Privacy Policy", @@ -65,7 +65,7 @@ "simplex-private-card-4-point-2": "To use SimpleX via Tor please install Orbot app and enable SOCKS5 proxy (or VPN on iOS).", "simplex-private-card-5-point-1": "SimpleX uses content padding for each encryption layer to frustrate message size attacks.", "simplex-private-card-5-point-2": "It makes messages of different sizes look the same to the servers and network observers.", - "simplex-private-card-6-point-1": "Many communication platforms are vulnerable to MITM attacks by servers or network providers.", + "simplex-private-card-6-point-1": "Many communication networks are vulnerable to MITM attacks by servers or network providers.", "simplex-private-card-6-point-2": "To prevent it SimpleX apps pass one-time keys out-of-band, when you share an address as a link or a QR code.", "simplex-private-card-7-point-1": "To guarantee integrity the messages are sequentially numbered and include the hash of the previous message.", "simplex-private-card-7-point-2": "If any message is added, removed or changed the recipient will be alerted.", @@ -92,7 +92,7 @@ "simplex-unique-4-title": "You own SimpleX network", "simplex-unique-4-overlay-1-title": "Fully decentralised — users own the SimpleX network", "hero-overlay-card-1-p-1": "Many users asked: if SimpleX has no user identifiers, how can it know where to deliver messages?", - "hero-overlay-card-1-p-2": "To deliver messages, instead of user IDs used by all other platforms, SimpleX uses temporary anonymous pairwise identifiers of message queues, separate for each of your connections — there are no long term identifiers.", + "hero-overlay-card-1-p-2": "To deliver messages, instead of user IDs used by all other networks, SimpleX uses temporary anonymous pairwise identifiers of message queues, separate for each of your connections — there are no long term identifiers.", "hero-overlay-card-1-p-3": "You define which server(s) to use to receive the messages, your contacts — the servers you use to send the messages to them. Every conversation is likely to use two different servers.", "hero-overlay-card-1-p-4": "This design prevents leaking any users' metadata on the application level. To further improve privacy and protect your IP address you can connect to messaging servers via Tor.", "hero-overlay-card-1-p-5": "Only client devices store user profiles, contacts and groups; the messages are sent with 2-layer end-to-end encryption.", @@ -108,38 +108,38 @@ "simplex-network-overlay-card-1-li-1": "P2P networks rely on some variant of DHT to route messages. DHT designs have to balance delivery guarantee and latency. SimpleX has both better delivery guarantee and lower latency than P2P, because the message can be redundantly passed via several servers in parallel, using the servers chosen by the recipient. In P2P networks the message is passed through O(log N) nodes sequentially, using nodes chosen by the algorithm.", "simplex-network-overlay-card-1-li-2": "SimpleX design, unlike most P2P networks, has no global user identifiers of any kind, even temporary, and only uses temporary pairwise identifiers, providing better anonymity and metadata protection.", "simplex-network-overlay-card-1-li-3": "P2P does not solve MITM attack problem, and most existing implementations do not use out-of-band messages for the initial key exchange. SimpleX uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange.", - "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic - it can work over standard web protocols, e.g. WebSockets.", + "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic — it can work over standard web protocols, e.g. WebSockets.", "simplex-network-overlay-card-1-li-5": "All known P2P networks may be vulnerable to Sybil attack, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive proof of work. SimpleX network has no server discoverability, it is fragmented and operates as multiple isolated sub-networks, making network-wide attacks impossible.", "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connection and cannot be used by an attacker to amplify the traffic in the whole network.", "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", "privacy-matters-overlay-card-1-p-2": "Online retailers know that people with lower incomes are more likely to make urgent purchases, so they may charge higher prices or remove discounts.", "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as 'poverty premium'.", - "privacy-matters-overlay-card-1-p-4": "SimpleX platform protects the privacy of your connections better than any alternative, fully preventing your social graph becoming available to any companies or organizations. Even when people use servers provided by SimpleX Chat, we do not know the number of users or their connections.", + "privacy-matters-overlay-card-1-p-4": "SimpleX network protects the privacy of your connections better than any alternative, fully preventing your social graph becoming available to any companies or organizations. Even when people use servers preconfigured in SimpleX Chat apps, server operators do not know the number of users or their connections.", "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", - "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use private communication platform that does not have access to your social graph.", - "privacy-matters-overlay-card-2-p-3": "SimpleX is the first platform that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", + "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use private communication network that does not have access to your social graph.", + "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", "privacy-matters-overlay-card-3-p-1": "Everyone should care about privacy and security of their communications — harmless conversations can put you in danger, even if you have nothing to hide.", "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their 'anonymous' accounts, even in democratic countries.", "privacy-matters-overlay-card-3-p-4": "It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks — who we are connected with.", - "simplex-unique-overlay-card-1-p-1": "Unlike other messaging platforms, SimpleX has no identifiers assigned to the users. It does not rely on phone numbers, domain-based addresses (like email or XMPP), usernames, public keys or even random numbers to identify its users — we don't know how many people use our SimpleX servers.", - "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers. Using SimpleX is like having a different “burner” email or phone for each contact, and no hassle to manage them.", - "simplex-unique-overlay-card-1-p-3": "This design protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers. To hide your IP address from the servers, you can connect to SimpleX servers via Tor.", - "simplex-unique-overlay-card-2-p-1": "Because you have no identifier on the SimpleX platform, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", + "simplex-unique-overlay-card-1-p-1": "Unlike other messaging networks, SimpleX has no identifiers assigned to the users. It does not rely on phone numbers, domain-based addresses (like email or XMPP), usernames, public keys or even random numbers to identify its users — SimpleX server operators don't know how many people use their servers.", + "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers.", + "simplex-unique-overlay-card-1-p-3": "This design protects the privacy of who you are communicating with, hiding it from SimpleX network servers and from any observers. To hide your IP address from the servers, you can connect to SimpleX servers via Tor.", + "simplex-unique-overlay-card-2-p-1": "Because you have no identifier on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", "simplex-unique-overlay-card-2-p-2": "Even with the optional user address, while it can be used to send spam contact requests, you can change or completely delete it without losing any of your connections.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat stores all user data only on client devices using a portable encrypted database format that can be exported and transferred to any supported device.", "simplex-unique-overlay-card-3-p-2": "The end-to-end encrypted messages are held temporarily on SimpleX relay servers until received, then they are permanently deleted.", "simplex-unique-overlay-card-3-p-3": "Unlike federated networks servers (email, XMPP or Matrix), SimpleX servers don't store user accounts, they only relay messages, protecting the privacy of both parties.", "simplex-unique-overlay-card-3-p-4": "There are no identifiers or ciphertext in common between sent and received server traffic — if anybody is observing it, they cannot easily determine who communicates with whom, even if TLS is compromised.", - "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the pre-configured servers provided by us.", - "simplex-unique-overlay-card-4-p-2": "SimpleX platform uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you can build.", - "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX platform, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", - "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX platform servers and any observers.", - "simplex-unique-card-1-p-2": "Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users — not even random numbers.", - "simplex-unique-card-2-p-1": "Because you have no identifier or fixed address on the SimpleX platform, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", + "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the servers preconfigured in the apps.", + "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you will build.", + "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", + "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX network servers and any observers.", + "simplex-unique-card-1-p-2": "Unlike any other existing messaging network, SimpleX has no identifiers assigned to the users — not even random numbers.", + "simplex-unique-card-2-p-1": "Because you have no identifier or fixed address on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", "simplex-unique-card-3-p-1": "SimpleX stores all user data on client devices in a portable encrypted database format — it can be transferred to another device.", "simplex-unique-card-3-p-2": "The end-to-end encrypted messages are held temporarily on SimpleX relay servers until received, then they are permanently deleted.", - "simplex-unique-card-4-p-1": "The SimpleX network is fully decentralised and independent of any crypto-currency or any other platform, other than the Internet.", + "simplex-unique-card-4-p-1": "The SimpleX network is fully decentralised and independent of any crypto-currency or any other network, other than the Internet.", "simplex-unique-card-4-p-2": "You can use SimpleX with your own servers or with the servers provided by us — and still connect to any user.", "join": "Join", "we-invite-you-to-join-the-conversation": "We invite you to join the conversation", @@ -149,8 +149,7 @@ "sign-up-to-receive-our-updates": "Sign up to receive our updates", "enter-your-email-address": "Enter your email address", "get-simplex": "Get SimpleX desktop app", - "why-simplex-is": "Why SimpleX is", - "unique": "unique", + "why-simplex-is-unique": "Why SimpleX is unique", "learn-more": "Learn more", "more-info": "More info", "hide-info": "Hide info", @@ -172,7 +171,7 @@ "use-this-command": "Use this command:", "see-simplex-chat": "See SimpleX Chat", "github-repository": "GitHub repository", - "the-instructions--source-code": "the instructions how to download or compile it from the source code.", + "the-instructions--source-code": "for the instructions how to download or compile it from the source code.", "if-you-already-installed-simplex-chat-for-the-terminal": "If you already installed SimpleX Chat for the terminal", "if-you-already-installed": "If you already installed", "simplex-chat-for-the-terminal": "SimpleX Chat for the terminal", @@ -213,7 +212,7 @@ "comparison-section-list-point-4a": "SimpleX relays cannot compromise e2e encryption. Verify security code to mitigate attack on out-of-band channel", "comparison-section-list-point-4": "If operator’s servers are compromised. Verify security code in Signal and some other apps to mitigate it", "comparison-section-list-point-5": "Does not protect users' metadata privacy", - "comparison-section-list-point-6": "While P2P are distributed, they are not federated - they operate as a single network", + "comparison-section-list-point-6": "While P2P are distributed, they are not federated — they operate as a single network", "comparison-section-list-point-7": "P2P networks either have a central authority or the whole network can be compromised", "see-here": "see here", "guide-dropdown-1": "Quick start", @@ -226,7 +225,7 @@ "guide-dropdown-8": "App settings", "guide-dropdown-9": "Making connections", "guide": "Guide", - "docs-dropdown-1": "SimpleX platform", + "docs-dropdown-1": "SimpleX network", "docs-dropdown-2": "Accessing Android files", "docs-dropdown-3": "Accessing chat database", "docs-dropdown-4": "Host SMP Server", diff --git a/website/langs/es.json b/website/langs/es.json index b88a592ac4..b3cfc83969 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -10,7 +10,7 @@ "simplex-explained-tab-3-p-2": "El usuario puede mejorar aún más la privacidad de sus metadatos haciendo uso de la red Tor para acceder a los servidores, evitando así la correlación por dirección IP.", "smp-protocol": "Protocolo SMP", "donate": "Donación", - "copyright-label": "© 2020-2024 SimpleX | Proyecto de Código Abierto", + "copyright-label": "© 2020-2025 SimpleX | Proyecto de Código Abierto", "simplex-chat-protocol": "Protocolo de SimpleX Chat", "terms-and-privacy-policy": "Política de Privacidad", "hero-header": "Privacidad redefinida", @@ -40,7 +40,7 @@ "simplex-private-card-7-point-2": "Si se añade, elimina o modifica algún mensaje, el destinatario es avisado.", "simplex-private-card-8-point-1": "Los servidores SimpleX actúan como nodos de mezcla de baja latencia — los mensajes entrantes y salientes siguen un orden diferente.", "simplex-private-card-9-point-1": "Cada cola de mensajes transmite los mensajes en un solo sentido, con las distintas direcciones de envío y recepción.", - "simplex-explained": "Simplex explicado", + "simplex-explained": "SimpleX explicado", "simplex-explained-tab-1-p-1": "Puedes crear contactos y grupos, y mantener conversaciones bidireccionales como en cualquier aplicación de mensajería.", "simplex-explained-tab-1-p-2": "¿Cómo puede funcionar con colas unidireccionales y sin identificadores de usuario?", "simplex-explained-tab-2-p-1": "Por cada conexión se usan dos colas de mensajes separadas para que el envío y recepción se hagan a través de servidores diferentes.", @@ -122,7 +122,7 @@ "privacy-matters-overlay-card-3-p-2": "Una de las historias más estremecedoras es la experiencia de Mohamedou Ould Salahi descrita en sus memorias y plasmada en la película The Mauritanian. Fue internado en la prisión militar de Guantánamo sin juicio previo, donde fue torturado durante 15 años tras una llamada telefónica a su pariente en Afganistán, bajo sospecha de estar implicado en los atentados del 11-S a pesar de que vivía en Alemania desde hacía 10 años.", "privacy-matters-overlay-card-3-p-4": "No basta con usar mensajería cifrada de extremo a extremo, todos deberíamos utilizar las aplicaciones de mensajería que protegen la privacidad de nuestras redes personales y con quién estamos conectados.", "privacy-matters-overlay-card-3-p-3": "Personas corrientes son detenidas por lo que comparten en Internet, incluso a través de sus cuentas \"anónimas\" e incluso en países democráticos.", - "simplex-unique-overlay-card-1-p-2": "Para enviar mensajes, SimpleX utiliza direcciones anónimas por pares de colas de mensajes unidireccionales, separadas para mensajes recibidos y enviados, y normalmente a través de servidores diferentes. Utilizar SimpleX es como tener un correo electrónico “desechable” o un teléfono diferente para cada contacto, pero sin las complicaciones que implicaría gestionarlos.", + "simplex-unique-overlay-card-1-p-2": "Para enviar mensajes, SimpleX utiliza direcciones anónimas por pares de colas de mensajes unidireccionales, separadas para mensajes recibidos y enviados, y normalmente a través de servidores diferentes.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat almacena todos los datos de usuario únicamente en los dispositivos cliente usando un formato cifrado y portable de la base de datos, la cual puede ser exportada y transferida a cualquier dispositivo compatible.", "simplex-unique-overlay-card-3-p-3": "A diferencia de los servidores de redes federadas (correo electrónico, XMPP o Matrix), los servidores SimpleX no almacenan cuentas de usuario, sólo retransmiten mensajes, protegiendo así la privacidad de ambas partes.", "simplex-unique-overlay-card-4-p-1": "Puede usar SimpleX con sus propios servidores y aún así comunicarte con personas conectadas a los servidores preconfigurados y proporcionados por nosotros.", @@ -138,8 +138,7 @@ "donate-here-to-help-us": "Para ayudarnos haga una donación aquí", "enter-your-email-address": "Escriba su dirección de correo electrónico", "get-simplex": "Obtenga SimpleX desktop app", - "why-simplex-is": "Por qué SimpleX es", - "unique": "único", + "why-simplex-is-unique": "Por qué SimpleX es único", "learn-more": "Descubra más", "more-info": "Más información", "hide-info": "Ocultar información", @@ -244,15 +243,17 @@ "signing-key-fingerprint": "Huella digital de la clave de firma (SHA-256)", "releases-to-this-repo-are-done-1-2-days-later": "Las versiones aparecen varios días más tarde en este repositorio", "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo", - "hero-overlay-3-title": "Evaluación de la seguridad", + "hero-overlay-3-title": "Evaluaciones de seguridad", "hero-overlay-card-3-p-2": "Trail of Bits revisó la criptografía y los componentes de red de la plataforma SimpleX en noviembre de 2022. Más información.", "jobs": "Únete al equipo", - "hero-overlay-3-textlink": "Evaluación de la seguridad", + "hero-overlay-3-textlink": "Evaluaciones de seguridad", "hero-overlay-card-3-p-1": "Trail of Bits es una consultora de seguridad y tecnología líder cuyos clientes incluyen grandes tecnológicas, agencias gubernamentales e importantes proyectos de blockchain.", "docs-dropdown-9": "Descargas", "please-enable-javascript": "Habilita JavaScript para ver el código QR.", "please-use-link-in-mobile-app": "Usa el enlace en la apliación móvil", "docs-dropdown-10": "Transparencia", "docs-dropdown-11": "FAQ", - "docs-dropdown-12": "Seguridad" + "docs-dropdown-12": "Seguridad", + "docs-dropdown-14": "SimpleX para empresas", + "hero-overlay-card-3-p-3": "Trail of Bits revisó el diseño criptográfico de los protocolos de red SimpleX en julio de 2024." } diff --git a/website/langs/fi.json b/website/langs/fi.json index 68c0d4f1b4..460aa7989b 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -112,7 +112,7 @@ "simplex-explained-tab-1-p-1": "Voit luoda yhteyshenkilöitä ja ryhmiä sekä käydä kaksisuuntaisia keskusteluja kuten missä tahansa muussa viestisovelluksessa.", "simplex-explained-tab-3-p-1": "Palvelimilla on erilliset anonyymit tunnistetiedot kullekin jonolle, eivätkä ne tiedä, mille käyttäjille ne kuuluvat.", "donate": "Lahjoita", - "copyright-label": "© 2020-2023 SimpleX | Avoin projekti", + "copyright-label": "© 2020-2025 SimpleX | Avoin projekti", "hero-p-1": "Muissa sovelluksissa on käyttäjätunnuksia: Signal, Matrix, Session, Briar, Jami, Cwtch, jne.
SimpleX ei käytä niitä, ei edes satunnaisia numeroita.
Tämä parantaa yksityisyyttäsi radikaalisti.", "simplex-private-1-title": "2 kerrosta päästä päähän salattua viestintää", "simplex-private-2-title": "Lisäkerros palvelimen salaukselle", @@ -168,7 +168,7 @@ "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, jopa demokraattisissa maissa.", "privacy-matters-overlay-card-3-p-4": "Ei riitä, että käytät päästä päähän salattua viestintäsovellusta, meidän kaikkien pitäisi käyttää viestintäsovelluksia, jotka suojelevat henkilökohtaisten verkostojemme yksityisyyttä — keiden kanssa olemme yhteydessä.", "simplex-unique-overlay-card-1-p-1": "Toisin kuin muut viestintäalustat, SimpleX:llä ei ole mitään tunnisteita käyttäjille. Se ei luota puhelinnumeroihin, verkkotunnuksiin perustuviin osoitteisiin (kuten sähköposti tai XMPP), käyttäjänimiin, julkisiin avaimiin tai edes satunnaisiin numeroihin tunnistaakseen käyttäjänsä — emme tiedä kuinka monta ihmistä käyttää SimpleX-palvelimiamme.", - "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää parittaisia nimettömiä osoitteita kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta. SimpleX:n käyttö on kuin eri “kertakäyttöinen” sähköposti tai puhelin jokaiselle yhteydelle, eikä sinun tarvitse vaivautua niiden hallitsemiseen.", + "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää parittaisia nimettömiä osoitteita kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta.", "simplex-unique-overlay-card-1-p-3": "Tämä suunnittelu suojaa sitä, kenen kanssa kommunikoit, piilottamalla sen SimpleX-alustan palvelimilta ja kaikilta havainnoijilta. Piilottaaksesi IP-osoitteesi palvelimilta, voit yhdistää SimpleX-palvelimiin Tor-verkon kautta.", "simplex-unique-overlay-card-2-p-1": "Koska sinulla ei ole tunnistetta SimpleX-alustalla, kukaan ei voi ottaa sinuun yhteyttä, ellei jaa kertakäyttöistä tai väliaikaista käyttäjäosoitetta, kuten QR-koodia tai linkkiä.", "simplex-unique-overlay-card-2-p-2": "Jopa valinnaisen käyttäjäosoitteen kanssa, vaikka sitä voitaisiin käyttää roskapostiyhteyspyyntöjen lähettämiseen, voit vaihtaa sen tai poistaa sen kokonaan menettämättä mitään yhteyksiäsi.", @@ -187,8 +187,7 @@ "sign-up-to-receive-our-updates": "Tilaa päivityksemme", "enter-your-email-address": "Syötä sähköpostiosoitteesi", "get-simplex": "Hanki SimpleX desktop app", - "why-simplex-is": "Miksi SimpleX on", - "unique": "ainutlaatuinen", + "why-simplex-is-unique": "Miksi SimpleX on ainutlaatuinen", "learn-more": "Lue lisää", "more-info": "Lisätietoja", "contact-hero-subheader": "Skannaa QR-koodi SimpleX Chat -sovelluksella puhelimessasi tai tabletissasi.", diff --git a/website/langs/fr.json b/website/langs/fr.json index 61be2c8621..efc8d6c9c6 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -7,7 +7,7 @@ "why-simplex": "Pourquoi SimpleX", "simplex-privacy": "Confidentialité SimpleX", "simplex-network": "Réseau SimpleX", - "simplex-explained": "Simplex expliqué", + "simplex-explained": "SimpleX expliqué", "simplex-explained-tab-1-text": "1. L'expérience des utilisateurs", "simplex-explained-tab-2-text": "2. Comment ça marche", "simplex-explained-tab-3-text": "3. Ce que voient les serveurs", @@ -21,7 +21,7 @@ "smp-protocol": "Protocole SMP", "chat-protocol": "Protocole de chat", "donate": "Faire un don", - "copyright-label": "© 2020-2024 SimpleX | Projet Open-Source", + "copyright-label": "© 2020-2025 SimpleX | Projet Open-Source", "simplex-chat-protocol": "Protocole SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Politique de confidentialité", @@ -118,7 +118,7 @@ "privacy-matters-overlay-card-3-p-3": "Des personnes lambda se font arrêter pour ce qu'elles partagent en ligne, même via leurs comptes \"anonymes\", même dans les pays démocratiques.", "privacy-matters-overlay-card-3-p-4": "Il ne suffit pas d'utiliser une messagerie chiffrée de bout en bout, nous devrions tous utiliser des messageries qui protègent la vie privée de nos réseaux personnels — les personnes avec lesquelles nous sommes connectés.", "simplex-unique-overlay-card-1-p-1": "Contrairement aux autres plateformes de messagerie, SimpleX n'a pas d'identifiant attribué aux utilisateurs. Il ne s'appuie pas sur des numéros de téléphone, des adresses basées sur des domaines (comme l'e-mail ou XMPP), des noms d'utilisateur, des clés publiques ou même des nombres aléatoires pour identifier ses utilisateurs — nous ne savons pas combien de personnes utilisent nos serveurs SimpleX.", - "simplex-unique-overlay-card-1-p-2": "Pour envoyer des messages, SimpleX utilise des adresses anonymes par paires de files d'attente de messages unidirectionnelles, séparées pour les messages reçus et envoyés, généralement via des serveurs différents. Utiliser SimpleX, c'est comme avoir un e-mail ou un téléphone jetable différent pour chaque contact, sans aucune difficulté pour les gérer.", + "simplex-unique-overlay-card-1-p-2": "Pour envoyer des messages, SimpleX utilise des adresses anonymes par paires de files d'attente de messages unidirectionnelles, séparées pour les messages reçus et envoyés, généralement via des serveurs différents.", "simplex-unique-overlay-card-1-p-3": "Cette approche protège la confidentialité des personnes avec lesquelles vous communiquez, en la cachant des serveurs de la plateforme SimpleX et de tout observateur. Pour cacher votre adresse IP aux serveurs, vous pouvez vous connecter aux serveurs SimpleX via Tor.", "simplex-unique-overlay-card-2-p-1": "Parce que vous n'avez pas d'identifiant sur la plateforme SimpleX, personne ne peut vous contacter sauf si vous partagez une adresse d'utilisateur unique ou temporaire, sous forme de code QR ou de lien .", "simplex-unique-overlay-card-2-p-2": "Même avec l'adresse utilisateur facultative, bien qu'elle puisse être utilisée pour envoyer des demandes de contact de spam, vous pouvez la modifier ou la supprimer complètement sans perdre aucune de vos connexions.", @@ -144,8 +144,7 @@ "sign-up-to-receive-our-updates": "Inscrivez-vous pour recevoir nos mises à jour", "enter-your-email-address": "Entrez votre adresse e-mail", "get-simplex": "Obtenir SimpleX desktop app", - "why-simplex-is": "Pourquoi SimpleX est", - "unique": "unique", + "why-simplex-is-unique": "Pourquoi SimpleX est unique", "learn-more": "En savoir plus", "more-info": "Plus d'infos", "hide-info": "Masquer les infos", @@ -245,15 +244,17 @@ "f-droid-org-repo": "Dépot F-Droid.org", "stable-versions-built-by-f-droid-org": "Versions stables créées par F-Droid.org", "comparison-section-list-point-4a": "Les relais SimpleX ne peuvent pas compromettre le chiffrement e2e. Vérifier le code de sécurité pour limiter les attaques sur le canal hors bande", - "hero-overlay-3-title": "Évaluation de sécurité", + "hero-overlay-3-title": "Évaluation de la sécurité", "hero-overlay-card-3-p-2": "Trail of Bits a examiné les composants cryptographiques et réseau de la plateforme SimpleX en novembre 2022. En savoir plus.", "jobs": "Rejoignez notre équipe", - "hero-overlay-3-textlink": "Évaluation de sécurité", + "hero-overlay-3-textlink": "Évaluations de la sécurité", "hero-overlay-card-3-p-1": "Trail of Bits est un cabinet leader dans le secteur de la sécurité et des technologies qui compte parmi ses clients des grandes entreprises de la tech, des agences gouvernementales et d'importants projets de blockchain.", "docs-dropdown-9": "Téléchargements", "please-enable-javascript": "Veuillez activer JavaScript pour voir le code QR.", "please-use-link-in-mobile-app": "Veuillez utiliser le lien dans l'application mobile", "docs-dropdown-10": "Transparence", "docs-dropdown-12": "Sécurité", - "docs-dropdown-11": "FAQ" + "docs-dropdown-11": "FAQ", + "hero-overlay-card-3-p-3": "Trail of Bits a examiné la conception cryptographique des protocoles réseau SimpleX en juillet 2024.", + "docs-dropdown-14": "SimpleX pour les entreprises" } diff --git a/website/langs/he.json b/website/langs/he.json index 4fd966f05d..4a61999896 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -53,7 +53,7 @@ "smp-protocol": "פרוטוקול SMP", "chat-protocol": "פרוטוקול צ'אט", "donate": "תרומה", - "copyright-label": "© 2020-2024 SimpleX | פרויקט קוד פתוח", + "copyright-label": "© 2020-2025 SimpleX | פרויקט קוד פתוח", "hero-p-1": "לאפליקציות אחרות יש מזהי משתמש: Signal, Matrix, Session, Briar, Jami, Cwtch וכו'.
ל-SimpleX אין, אפילו לא מספרים אקראיים.
זה משפר באופן קיצוני את הפרטיות שלך.", "hero-overlay-2-title": "מדוע מזהי משתמש מזיקים לפרטיות?", "feature-6-title": "שיחות שמע ווידאו
מוצפנות מקצה לקצה", @@ -75,12 +75,12 @@ "privacy-matters-2-overlay-1-linkText": "פרטיות מעניקה לכם עוצמה", "privacy-matters-3-overlay-1-linkText": "פרטיות מגנה על החופש שלכם", "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות.", - "hero-overlay-3-title": "הערכת אבטחה", + "hero-overlay-3-title": "הערכות אבטחה", "simplex-unique-1-overlay-1-title": "פרטיות מלאה של הזהות, הפרופיל, אנשי הקשר והמטא נתונים שלך", "simplex-unique-3-overlay-1-title": "בעלות, שליטה ואבטחה של הנתונים שלך", "simplex-unique-2-overlay-1-title": "ההגנה הטובה ביותר מפני ספאם וניצול לרעה", "simplex-unique-3-title": "אתה שולט בנתונים שלך", - "hero-overlay-3-textlink": "הערכת אבטחה", + "hero-overlay-3-textlink": "הערכות אבטחה", "simplex-unique-4-overlay-1-title": "מבוזר לחלוטין — המשתמשים הם הבעלים של רשת SimpleX", "simplex-unique-2-title": "אתה מוגן
מפני ספאם וניצול לרעה", "simplex-unique-4-title": "רשת SimpleX בבעלותך", @@ -163,10 +163,9 @@ "simplex-unique-overlay-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", "simplex-unique-overlay-card-4-p-2": "פלטפורמת SimpleX משתמשת בפרוטוקול פתוח ומספקת SDK ליצירת צ'אט בוטים, המאפשר הטמעה של שירותים שמשתמשים יכולים לתקשר איתם באמצעות אפליקציות SimpleX Chat — אנחנו ממש מצפים לראות אילו שירותי SimpleX אתם יכולים לבנות.", "contact-hero-p-2": "עדיין לא הורדתם את ה-SimpleX Chat?", - "why-simplex-is": "מדוע SimpleX הוא", + "why-simplex-is-unique": "מדוע SimpleX הוא ייחודי", "simplex-network-section-header": "רשת SimpleX", "tap-the-connect-button-in-the-app": "הקישו על הלחצן 'התחבר' באפליקציה", - "unique": "ייחודי", "simplex-network-1-overlay-linktext": "בעיות של רשתות P2P", "protocol-2-text": "XMPP, Matrix", "simplex-network-overlay-card-1-li-4": "יישומי P2P יכולים להיחסם על ידי ספקי אינטרנט מסוימים (כמו BitTorrent). SimpleX הוא אגנוסטי לתעבורה - הוא יכול לעבוד על פרוטוקולי אינטרנט סטנדרטיים, למשל WebSockets.", @@ -254,5 +253,7 @@ "please-use-link-in-mobile-app": "אנא השתמש בקישור באפליקציה במכשיר נייד", "docs-dropdown-10": "שקיפות", "docs-dropdown-11": "שאלות ותשובות", - "docs-dropdown-12": "אבטחה" + "docs-dropdown-12": "אבטחה", + "hero-overlay-card-3-p-3": "Trail of bits סקר את הקוד הקריפטוגרפי של פרוטוקולי רשת SimpleX ביולי 2024. קרא עוד.", + "docs-dropdown-14": "SimpleX לעסקים" } diff --git a/website/langs/hu.json b/website/langs/hu.json index 7702b56f47..f512cfa7cf 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -1,37 +1,37 @@ { "home": "Kezdőoldal", "developers": "Fejlesztők", - "reference": "Referencia", + "reference": "Hivatkozás", "blog": "Blog", "features": "Funkciók", "why-simplex": "Miért válassza a SimpleXet", - "simplex-privacy": "SimpleX-adatvédelem", - "simplex-network": "SimpleX-hálózat", - "simplex-explained": "A Simplex bemutatása", + "simplex-privacy": "A SimpleX adatvédelme", + "simplex-network": "A SimpleX-hálózat", + "simplex-explained": "A SimpleX bemutatása", "simplex-explained-tab-1-text": "1. Felhasználói élmény", "simplex-explained-tab-2-text": "2. Hogyan működik", "simplex-explained-tab-3-text": "3. Mit látnak a kiszolgálók", - "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetküldőben.", - "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú üzenet sorbaállítással és felhasználói profil-azonosítók nélkül?", + "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetváltó-alkalmazásban.", + "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú üzenet-sorballítással és felhasználói profilazonosítók nélkül?", "simplex-explained-tab-2-p-1": "Minden kapcsolathoz két különböző üzenetküldési sorbaállítást használ a különböző kiszolgálókon keresztül történő üzenetküldéshez és -fogadáshoz.", - "simplex-explained-tab-2-p-2": "A kiszolgálók csak egyirányú üzeneteket továbbítanak, anélkül, hogy teljes képet kapnának a felhasználók beszélgetéseiről vagy kapcsolatairól.", + "simplex-explained-tab-2-p-2": "A kiszolgálók csak egyetlen irányba továbbítják az üzeneteket, anélkül, hogy teljes képet kapnának a felhasználók beszélgetéseiről vagy kapcsolatairól.", "simplex-explained-tab-3-p-1": "A kiszolgálók minden egyes üzenetsorbaállításhoz külön névtelen hitelesítő-adatokkal rendelkeznek, és nem tudják, hogy melyik felhasználóhoz tartoznak.", - "simplex-explained-tab-3-p-2": "A felhasználók tovább fokozhatják a metaadatok adatvédelmét, ha a Tor segítségével férnek hozzá a kiszolgálókhoz, így megakadályozva az IP-cím szerinti korrelációt.", + "simplex-explained-tab-3-p-2": "A felhasználók tovább fokozhatják a metaadatok adatvédelmét, ha a Tor-hálózat használatával férnek hozzá a kiszolgálókhoz, így megakadályozva az IP-cím szerinti korrelációt.", "smp-protocol": "SMP-protokoll", - "chat-protocol": "Csevegésprotokoll", - "donate": "Támogatás", - "copyright-label": "© 2020-2024 SimpleX | Nyílt forráskódú projekt", - "simplex-chat-protocol": "SimpleX Chat-protokoll", + "chat-protocol": "Csevegési protokoll", + "donate": "Adományozás", + "copyright-label": "© 2020-2025 SimpleX | Nyílt forráskódú projekt", + "simplex-chat-protocol": "A SimpleX Chat-protokoll", "terminal-cli": "Terminál CLI", "terms-and-privacy-policy": "Adatvédelmi irányelvek", - "hero-header": "Újradefiniált adatvédelem", - "hero-subheader": "Az első üzenetküldő
felhasználói azonosítók nélkül", - "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", + "hero-header": "Újraértelmezett adatvédelem", + "hero-subheader": "Az első üzenetváltó-alkalmazás
felhasználói azonosítók nélkül", + "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX azonban nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", "hero-overlay-1-textlink": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "hero-overlay-2-textlink": "Hogyan működik a SimpleX?", "hero-overlay-3-textlink": "Biztonsági felmérések", "hero-2-header": "Privát kapcsolat létrehozása", - "hero-2-header-desc": "A videó bemutatja, hogyan kapcsolódhat az ismerőséhez egy egyszer használható QR-kód segítségével, személyesen vagy videokapcsolaton keresztül. Ugyanakkor egy meghívó-hivatkozás megosztásával is kapcsolódhat.", + "hero-2-header-desc": "A videó bemutatja, hogyan kapcsolódhat a partneréhez egy egyszer használható QR-kód segítségével, személyesen vagy videóhíváson keresztül. Ugyanakkor egy meghívási hivatkozás megosztásával is kapcsolódhat.", "hero-overlay-1-title": "Hogyan működik a SimpleX?", "hero-overlay-2-title": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "hero-overlay-3-title": "Biztonsági felmérések", @@ -40,33 +40,33 @@ "feature-3-title": "E2E-titkosított decentralizált csoportok — csak a felhasználók tudják, hogy ezek léteznek", "feature-4-title": "E2E-titkosított hangüzenetek", "feature-5-title": "Eltűnő üzenetek", - "feature-6-title": "E2E-titkosított
hang- és videohívások", + "feature-6-title": "E2E-titkosított
hang- és videóhívások", "feature-7-title": "Hordozható titkosított alkalmazás-adattárolás — profil átköltöztetése egy másik eszközre", "feature-8-title": "Az inkognitómód —
egyedülálló a SimpleX Chatben", - "simplex-network-overlay-1-title": "Összehasonlítás más P2P üzenetküldő protokollokkal", + "simplex-network-overlay-1-title": "Összehasonlítás más P2P-üzenetküldő protokollokkal", "simplex-private-1-title": "2 rétegű végpontok közötti titkosítás", "simplex-private-2-title": "További rétege a
kiszolgáló-titkosítás", - "simplex-private-4-title": "Nem kötelező
hozzáférés Tor-on keresztül", - "simplex-private-5-title": "Több rétegű
tartalom kitöltés", + "simplex-private-4-title": "Hozzáférés Tor-hálózaton keresztül
(nem kötelező)", + "simplex-private-5-title": "Több rétegű
tartalomkitöltés", "simplex-private-6-title": "Sávon kívüli
kulcscsere", "simplex-private-7-title": "Üzenetintegritás
hitelesítés", "simplex-private-8-title": "Üzenetek keverése
a korreláció csökkentése érdekében", "simplex-private-9-title": "Egyirányú
üzenetsorbaállítás", "simplex-private-10-title": "Ideiglenes, névtelen, páronkénti azonosítók", "simplex-private-card-1-point-1": "Double-Ratchet-protokoll —
OTR-üzenetküldés, sérülés utáni titkosság-védelemmel és -helyreállítással.", - "simplex-private-card-1-point-2": "NaCL cryptobox minden egyes üzenet sorbaállításához, hogy megakadályozza a forgalom korrelációját az üzenet-sorbaállítások között, ha a TLS veszélybe kerül.", + "simplex-private-card-1-point-2": "NaCL cryptobox minden egyes üzenet sorbaállításához, hogy megakadályozza a forgalom korrelációját az üzenetsorbaállítások között, ha a TLS veszélybe kerül.", "simplex-private-card-2-point-1": "Kiegészítő kiszolgáló titkosítási réteg a címzettnek történő kézbesítéshez, hogy megakadályozza a fogadott és az elküldött kiszolgálóforgalom közötti korrelációt, ha a TLS veszélybe kerül.", "simplex-private-card-3-point-1": "A kliens és a kiszolgálók közötti kapcsolatokhoz csak az erős algoritmusokkal rendelkező TLS 1.2/1.3 protokollt használja.", "simplex-private-card-3-point-2": "A kiszolgáló ujjlenyomata és a csatornakötés megakadályozza a MITM- és a visszajátszási támadásokat.", "simplex-private-card-3-point-3": "Az újrakapcsolódás le van tiltva a munkamenet elleni támadások megelőzése érdekében.", - "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a TORon vagy más átvitel-átfedő-hálózaton keresztül is elérheti.", - "simplex-private-card-6-point-1": "Számos kommunikációs platform sebezhető a kiszolgálók vagy a hálózat-szolgáltatók MITM-támadásaival szemben.", + "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a Tor-hálózaton vagy más átvitelátfedő hálózaton keresztül is elérheti.", + "simplex-private-card-6-point-1": "Számos kommunikációs hálózat sebezhető a kiszolgálók vagy a hálózat-szolgáltatók MITM-támadásaival szemben.", "simplex-private-card-6-point-2": "Ennek megakadályozása érdekében a SimpleX-alkalmazások egyszeri kulcsokat adnak át sávon kívül, amikor egy címet hivatkozásként vagy QR-kódként oszt meg.", "simplex-private-card-7-point-1": "Az integritás garantálása érdekében az üzenetek sorszámozással vannak ellátva, és tartalmazzák az előző üzenet hasítóértékét.", "simplex-private-card-7-point-2": "Ha bármilyen üzenetet hozzáadnak, eltávolítanak vagy módosítanak, a címzett értesítést kap róla.", "simplex-private-card-8-point-1": "A SimpleX-kiszolgálók alacsony késleltetésű keverési csomópontokként működnek — a bejövő és kimenő üzenetek sorrendje eltérő.", "simplex-private-card-9-point-1": "Minden üzenetsorbaállítás egy irányba továbbítja az üzeneteket, a különböző küldési és vételi címekkel.", - "simplex-private-card-9-point-2": "A hagyományos üzenetküldőkhöz képest csökkenti a támadási vektorokat és a rendelkezésre álló metaadatokat.", + "simplex-private-card-9-point-2": "Kevesebb támadási felülettel rendelkezik, mint a hagyományos üzenetváltó alkalmazások, és kevesebb metaadatot tesz elérhetővé.", "simplex-private-card-10-point-1": "A SimpleX ideiglenes, névtelen, páros címeket és hitelesítő adatokat használ minden egyes felhasználói kapcsolathoz vagy csoporttaghoz.", "simplex-private-card-10-point-2": "Lehetővé teszi az üzenetek felhasználói profilazonosítók nélküli kézbesítését, ami az alternatíváknál jobb metaadat-védelmet biztosít.", "privacy-matters-1-overlay-1-title": "Az adatvédelemmel pénzt spórol meg", @@ -79,23 +79,23 @@ "privacy-matters-3-overlay-1-linkText": "Az adatvédelem szabaddá tesz", "simplex-unique-1-title": "Teljes magánéletet élvezhet", "simplex-unique-1-overlay-1-title": "Személyazonosságának, profiljának, kapcsolatainak és metaadatainak teljes körű védelme", - "simplex-unique-2-title": "Véd
a kéretlen üzenetektől és a visszaélésektől", - "simplex-unique-2-overlay-1-title": "A legjobb védelem a kéretlen üzenetek és a visszaélések ellen", + "simplex-unique-2-title": "Véd
a kéretlen tartalmaktól és a visszaélésektől", + "simplex-unique-2-overlay-1-title": "A legjobb védelem a kéretlen tartalmak és a visszaélések ellen", "simplex-unique-3-title": "Ön kezeli az adatait", "simplex-unique-3-overlay-1-title": "Az adatok biztonsága és kezelése az Ön kezében van", "simplex-unique-4-title": "Öné a SimpleX-hálózat", "simplex-unique-4-overlay-1-title": "Teljesen decentralizált — a SimpleX-hálózat a felhasználóké", "hero-overlay-card-1-p-1": "Sok felhasználó kérdezte: ha a SimpleXnek nincsenek felhasználói azonosítói, honnan tudja, hogy hová kell eljuttatni az üzeneteket?", - "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi platform által használt felhasználói azonosítók helyett a SimpleX az üzenetek sorbaállításához ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", - "hero-overlay-card-1-p-4": "Ez a kialakítás megakadályozza a felhasználók metaadatainak kiszivárgását az alkalmazás szintjén. Az adatvédelem további javítása és az IP-cím védelme érdekében az üzenetküldő kiszolgálókhoz TOR hálózaton keresztül is kapcsolódhat.", + "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi hálózat által használt felhasználói azonosítók helyett a SimpleX az üzenetek sorbaállításához ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", + "hero-overlay-card-1-p-4": "Ez a kialakítás megakadályozza a felhasználók metaadatainak kiszivárgását az alkalmazás szintjén. Az adatvédelem további javítása és az IP-cím védelme érdekében az üzenetküldő kiszolgálókhoz Tor-hálózaton keresztül is kapcsolódhat.", "hero-overlay-card-1-p-5": "Csak a kliensek tárolják a felhasználói profilokat, kapcsolatokat és csoportokat; az üzenetek küldése 2 rétegű végpontok közötti titkosítással történik.", "hero-overlay-card-1-p-6": "További leírást a SimpleX ismertetőben olvashat.", "hero-overlay-card-2-p-1": "Ha a felhasználók állandó azonosítóval rendelkeznek, még akkor is, ha ez csak egy véletlenszerű szám, például egy munkamenet-azonosító, fennáll annak a veszélye, hogy a szolgáltató vagy egy támadó megfigyelheti, azt hogy hogyan kapcsolódnak a felhasználók egymáshoz, és hány üzenetet küldenek egymásnak.", "hero-overlay-card-2-p-2": "Ezt az információt aztán összefüggésbe hozhatják a meglévő nyilvános közösségi hálózatokkal, és meghatározhatnak néhány valódi személyazonosságot.", - "hero-overlay-card-2-p-3": "Még a TOR v3 szolgáltatásokat használó, legprivátabb alkalmazások esetében is, ha két különböző kapcsolattartóval beszél ugyanazon a profilon keresztül, bizonyítani tudják, hogy ugyanahhoz a személyhez kapcsolódnak.", + "hero-overlay-card-2-p-3": "Még a Tor v3 szolgáltatásokat használó, legprivátabb alkalmazások esetében is, ha két különböző kapcsolattartóval beszél ugyanazon a profilon keresztül, bizonyítani tudják, hogy ugyanahhoz a személyhez kapcsolódnak.", "hero-overlay-card-2-p-4": "A SimpleX úgy védekezik ezen támadások ellen, hogy nem tartalmaz felhasználói azonosítókat. Ha pedig használja az inkognitómódot, akkor minden egyes létrejött kapcsolatban más-más felhasználó név jelenik meg, így elkerülhető a közöttük lévő összefüggések teljes bizonyítása.", "hero-overlay-card-3-p-1": "Trail of Bits egy vezető biztonsági és technológiai tanácsadó cég, amelynek az ügyfelei közé tartoznak nagy technológiai cégek, kormányzati ügynökségek és jelentős blokklánc projektek.", - "hero-overlay-card-3-p-2": "A Trail of Bits 2022 novemberében áttekintette a SimpleX-platform kriptográfiai és hálózati komponenseit. További információk.", + "hero-overlay-card-3-p-2": "A Trail of Bits 2022 novemberében auditálta a SimpleX-platform kriptográfiai és hálózati komponenseit. További információk.", "simplex-network-overlay-card-1-li-1": "A P2P-hálózatok az üzenetek továbbítására a DHT valamelyik változatát használják. A DHT kialakításakor egyensúlyt kell teremteni a kézbesítési garancia és a késleltetés között. A SimpleX jobb kézbesítési garanciával és alacsonyabb késleltetéssel rendelkezik, mint a P2P, mivel az üzenet redundánsan, a címzett által kiválasztott kiszolgálók segítségével több kiszolgálón keresztül párhuzamosan továbbítható. A P2P-hálózatokban az üzenet O(log N) csomóponton halad át szekvenciálisan, az algoritmus által kiválasztott csomópontok segítségével.", "simplex-network-overlay-card-1-li-2": "A SimpleX kialakítása a legtöbb P2P-hálózattól eltérően nem rendelkezik semmiféle globális felhasználói azonosítóval, még ideiglenessel sem, és csak az üzenetekhez használ ideiglenes, páros azonosítókat, ami jobb névtelenséget és metaadatvédelmet biztosít.", "simplex-network-overlay-card-1-li-3": "A P2P nem oldja meg a MITM-támadás problémát, és a legtöbb létező implementáció nem használ sávon kívüli üzeneteket a kezdeti kulcscseréhez. A SimpleX a kezdeti kulcscseréhez sávon kívüli üzeneteket, vagy bizonyos esetekben már meglévő biztonságos és megbízható kapcsolatokat használ.", @@ -104,81 +104,80 @@ "privacy-matters-overlay-card-1-p-1": "Sok nagyvállalat arra használja fel az önnel kapcsolatban álló személyek adatait, hogy megbecsülje az ön jövedelmét, hogy olyan termékeket adjon el önnek, amelyekre valójában nincs is szüksége, és hogy meghatározza az árakat.", "privacy-matters-overlay-card-1-p-2": "Az online kiskereskedők tudják, hogy az alacsonyabb jövedelműek nagyobb valószínűséggel vásárolnak azonnal, ezért magasabb árakat számíthatnak fel, vagy eltörölhetik a kedvezményeket.", "privacy-matters-overlay-card-1-p-3": "Egyes pénzügyi és biztosítótársaságok szociális grafikonokat használnak a kamatlábak és a díjak meghatározásához. Ez gyakran arra készteti az alacsonyabb jövedelmű embereket, hogy többet fizessenek — ez az úgynevezett „szegénységi prémium”.", - "privacy-matters-overlay-card-1-p-4": "A SimpleX-platform minden alternatívánál jobban védi a kapcsolatainak adatait, teljes mértékben megakadályozva, hogy a ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által biztosított kiszolgálókat is használják, sem a felhasználók számát, sem a kapcsolataikat nem ismerjük.", + "privacy-matters-overlay-card-1-p-4": "A SimpleX-hálózat minden alternatívánál jobban védi a kapcsolatainak adatait, teljes mértékben megakadályozva, hogy az ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által előre beállított kiszolgálókat is használják, sem az alkalmazások, sem a kiszolgálók üzemeltetői nem ismerik, sem felhasználók számát, sem a kapcsolataikat.", "privacy-matters-overlay-card-2-p-1": "Nem is olyan régen megfigyelhettük, hogy a nagy választásokat manipulálta egy neves tanácsadó cég, amely az ismeretségi-háló segítségével eltorzította a valós világról alkotott képünket, és manipulálta a szavazatainkat.", - "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha privát kommunikációs platformot használ, amely nem fér hozzá az ismeretségi-hálójához.", - "privacy-matters-overlay-card-2-p-3": "A SimpleX az első olyan platform, amely eleve nem rendelkezik felhasználói azonosítókkal, így jobban védi az ismeretségi-hálóját, mint bármely ismert alternatíva.", + "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha privát kommunikációs hálózatot használ, amely nem fér hozzá az ismeretségi-hálójához.", + "privacy-matters-overlay-card-2-p-3": "A SimpleX az első olyan hálózat, amely eleve nem rendelkezik felhasználói azonosítókkal, így jobban védi az ismeretségi-hálóját, mint bármely ismert alternatíva.", "privacy-matters-overlay-card-3-p-1": "Mindenkinek törődnie kell a magánélet és a kommunikáció biztonságával — az ártalmatlan beszélgetések veszélybe sodorhatják, még akkor is, ha nincs semmi rejtegetnivalója.", "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", - "privacy-matters-overlay-card-3-p-4": "Nem elég, ha csak egy végpontok között titkosított üzenetküldőt használunk, mindannyiunknak olyan üzenetküldőket kell használnunk, amelyek védik személyes ismerőseink magánéletét — akikkel kapcsolatban állunk.", - "simplex-unique-overlay-card-1-p-1": "Más üzenetküldő platformoktól eltérően a SimpleX nem rendel azonosítókat a felhasználókhoz. Nem támaszkodik telefonszámokra, tartomány-alapú címekre (mint az e-mail, XMPP vagy a Matrix), felhasználónevekre, nyilvános kulcsokra vagy akár véletlenszerű számokra a felhasználók azonosításához — nem tudjuk, hogy hányan használják a SimpleX-kiszolgálóinkat.", - "simplex-unique-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez a SimpleX az egyirányú üzenet várakoztatást használ páronkénti névtelen címekkel, külön a fogadott és külön az elküldött üzenetek számára, általában különböző kiszolgálókon keresztül. A SimpleX használata olyan, mintha minden egyes kapcsolatnak más-más “eldobható” e-mail címe vagy telefonja lenne és nem kell ezeket gondosan kezelni.", - "simplex-unique-overlay-card-1-p-3": "Ez a kialakítás megvédi annak titkosságát, hogy kivel kommunikál, elrejtve azt a SimpleX platform kiszolgálói és a megfigyelők elől. IP-címének a kiszolgálók elől való elrejtéséhez azt teheti meg, hogy Toron keresztül kapcsolódik a SimpleX-kiszolgálókhoz.", - "simplex-unique-overlay-card-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", - "simplex-unique-overlay-card-2-p-2": "Még a nem kötelező felhasználói cím esetében is, bár spam kapcsolatfelvételi kérések küldésére használható, megváltoztathatja vagy teljesen törölheti azt anélkül, hogy elveszítené a meglévő kapcsolatait.", + "privacy-matters-overlay-card-3-p-4": "Nem elég csak egy végpontok között titkosított üzenetváltó-alkalmazást használnunk, mindannyiunknak olyan üzenetváltó-alkalmazásokat kell használnunk, amelyek védik a személyes partnereink magánéletét — akikkel kapcsolatban állunk.", + "simplex-unique-overlay-card-1-p-1": "Más üzenetküldő hálózatoktól eltérően a SimpleX nem rendel azonosítókat a felhasználókhoz. Nem támaszkodik telefonszámokra, tartomány-alapú címekre (mint az e-mail, XMPP vagy a Matrix), felhasználónevekre, nyilvános kulcsokra vagy akár véletlenszerű számokra a felhasználók azonosításához — a SimpleX-kiszolgálók üzemeltetői nem tudják, hogy hányan használják a kiszolgálóikat.", + "simplex-unique-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez a SimpleX az egyirányú üzenet várakoztatást használ páronkénti névtelen címekkel, külön a fogadott és külön az elküldött üzenetek számára, általában különböző kiszolgálókon keresztül.", + "simplex-unique-overlay-card-1-p-3": "Ez a kialakítás védi partnerének adatait, elrejtve azt a SimpleX-hálózat kiszolgálói és a külső megfigyelők elől. Az IP-címe elrejtésének érdekében aTor-hálózaton keresztül is kapcsolódhat a SimpleX-kiszolgálókhoz.", + "simplex-unique-overlay-card-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-hálózaton, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", + "simplex-unique-overlay-card-2-p-2": "Még a felhasználói cím használata esetén is, aminek használata nem kötelező – ugyanakkor ez a kéretlen kapcsolatkérelmek küldésére is használható – módosíthatja vagy teljesen törölheti anélkül, hogy elveszítené a meglévő kapcsolatait.", "simplex-unique-overlay-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban, amely exportálható és átvihető bármely más támogatott eszközre.", - "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-közvetítő-kiszolgálókon tartózkodnak, amíg be nem érkeznek a címzetthez, majd véglegesen törlődnek onnan.", - "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak közvetítik az üzeneteket, így védve mindkét fél magánéletét.", + "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", + "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", - "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az általunk biztosított, előre konfigurált kiszolgálókat használják.", - "simplex-unique-overlay-card-4-p-2": "A SimpleX platform nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", - "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX-platformra való fejlesztést fontolgatja, például a SimpleX-alkalmazások felhasználóinak szánt chatbotot, vagy a SimpleX Chat-könvtárbot integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", - "simplex-unique-card-1-p-1": "A SimpleX védi az ön profiljához tartozó kapcsolatait és metaadatait, elrejtve azokat a SimpleX platform kiszolgálói és a megfigyelők elől.", - "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő platformtól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", - "simplex-unique-card-2-p-1": "Mivel a SimpleX platformon nincs azonosítója vagy állandó címe, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", + "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az előre beállított kiszolgálókat használják az alkalmazásban.", + "simplex-unique-overlay-card-4-p-2": "A SimpleX-hálózat nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", + "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX-hálózatra való fejlesztést fontolgatja, például a SimpleX-alkalmazások felhasználóinak szánt chatbotot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", + "simplex-unique-card-1-p-1": "A SimpleX megvédi a profilhoz tartozó kapcsolatokat és metaadatokat, elrejtve azokat a SimpleX-hálózat kiszolgálói és a megfigyelők elől.", + "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő hálózattól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", + "simplex-unique-card-2-p-1": "Mivel a SimpleX-hálózaton nincs azonosítója vagy állandó címe, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", "simplex-unique-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban —, amely exportálható és átvihető bármely más támogatott eszközre.", - "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-közvetítő-kiszolgálókon tartózkodnak, amíg meg nem érkeznek a címzetthez, majd ezt követően véglegesen törlésre kerülnek.", - "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", - "simplex-unique-card-4-p-2": "Használhatja a SimpleX-et saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", - "join": "Csatlakozás", - "we-invite-you-to-join-the-conversation": "Meghívjuk önt, hogy csatlakozzon a beszélgetéshez", + "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", + "simplex-unique-card-4-p-1": "A SimpleX-hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más hálózattól, kivéve az internetet.", + "simplex-unique-card-4-p-2": "Használhatja a SimpleXet a saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", + "join": "Csatlakozzon a közösségeinkhez", + "we-invite-you-to-join-the-conversation": "Meghívjuk Önt, hogy csatlakozzon a beszélgetésekhez", "join-the-REDDIT-community": "Csatlakozzon a REDDIT közösséghez", "join-us-on-GitHub": "Csatlakozzon hozzánk a GitHubon", "donate-here-to-help-us": "Adományozzon és segítsen nekünk", - "sign-up-to-receive-our-updates": "Regisztráljon az oldalra, hogy megkapja frissítéseinket", - "enter-your-email-address": "Adja meg az e-mail címét", - "get-simplex": "SimpleX Desktop alkalmazás letöltése", - "why-simplex-is": "A SimpleX mitől", - "unique": "egyedülálló", + "sign-up-to-receive-our-updates": "Regisztráljon a hírleveleinkre, hogy ne maradjon le semmiről", + "enter-your-email-address": "Adja meg az e-mail-címét", + "get-simplex": "A SimpleX számítógépes alkalmazásának letöltése", + "why-simplex-is-unique": "A SimpleX mitől egyedülálló", "learn-more": "Tudjon meg többet", "more-info": "További információ", "hide-info": "Információ elrejtése", - "contact-hero-subheader": "Szkennelje be a QR-kódot a SimpleX Chat alkalmazással telefonján vagy táblagépén.", - "contact-hero-p-1": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldi el a hálózaton keresztül az oldal megtekintésekor — ezeket a hivatkozás URL-jének hash-töredéke tartalmazza.", + "contact-hero-subheader": "Olvassa be a QR-kódot a SimpleX Chat alkalmazással a telefonjával vagy táblagépével.", + "contact-hero-p-1": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldi el a hálózaton keresztül, amikor megtekinti ezt az oldalt — azokat, a hivatkozás webcímének hasítóérték-töredéke tartalmazza.", "contact-hero-p-2": "Még nem töltötte le a SimpleX Chatet?", - "contact-hero-p-3": "Az alkalmazás letöltéséhez használja az alábbi linkeket.", + "contact-hero-p-3": "Az alkalmazás letöltéséhez használja az alábbi hivatkozásokat.", "scan-qr-code-from-mobile-app": "QR-kód beolvasása mobilalkalmazásból", "to-make-a-connection": "A kapcsolat létrehozásához:", "install-simplex-app": "Telepítse a SimpleX alkalmazást", "open-simplex-app": "Simplex alkalmazás megnyitása", "tap-the-connect-button-in-the-app": "Koppintson a „kapcsolódás” gombra az alkalmazásban", - "scan-the-qr-code-with-the-simplex-chat-app": "A QR-kód beolvasása a SimpleX Chat alkalmazással", - "scan-the-qr-code-with-the-simplex-chat-app-description": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldjük el a hálózaton keresztül, amikor ezt az oldalt megtekinti —
ezek a hivatkozás URL-jének hash-töredékében szerepelnek.", - "installing-simplex-chat-to-terminal": "A SimpleX chat telepítése terminálba", + "scan-the-qr-code-with-the-simplex-chat-app": "Olvassa be a QR-kódot a SimpleX Chat alkalmazással", + "scan-the-qr-code-with-the-simplex-chat-app-description": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldjük el a hálózaton keresztül, amikor ezt az oldalt megtekinti —
ezek a hivatkozás webcímének hasítóérték-töredékében szerepelnek.", + "installing-simplex-chat-to-terminal": "A SimpleX chat telepítése a terminálhoz", "use-this-command": "Használja ezt a parancsot:", - "see-simplex-chat": "Lásd SimpleX Chat", + "see-simplex-chat": "Az utasításokat megtekintheti a SimpleX Chat", "connect-in-app": "Kapcsolódás az alkalmazásban", - "the-instructions--source-code": "az utasításokat, hogyan töltse le vagy fordítsa le a forráskódból.", - "if-you-already-installed-simplex-chat-for-the-terminal": "Ha már telepítette a SimpleX Chat-et a terminálba", + "the-instructions--source-code": "és választ kaphat arra, hogy hogyan töltse le vagy fordítsa le a forráskódból.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Ha már telepítette a SimpleX Chatet a terminálhoz", "if-you-already-installed": "Ha már telepítette a", - "simplex-chat-for-the-terminal": "SimpleX Chat-et a terminálba", + "simplex-chat-for-the-terminal": "SimpleX Chat a terminálhoz", "copy-the-command-below-text": "másolja be az alábbi parancsot, és használja a csevegésben:", "privacy-matters-section-header": "Miért számít az adatvédelem", - "privacy-matters-section-subheader": "A metaadatok védelmének megőrzése — kivel beszélget — megvédi a következőktől:", - "privacy-matters-section-label": "Győződjön meg róla, hogy az üzenetküldő amit használ nem fér hozzá az adataidhoz!", + "privacy-matters-section-subheader": "A metaadatok — pédául, hogy kivel beszélget — védelmének megőrzése biztonságot nyújt a következők ellen:", + "privacy-matters-section-label": "Győződjön meg arról, hogy az üzenetváltó-alkalmazás amit használ nem fér hozzá az adataihoz!", "simplex-private-section-header": "Mitől lesz a SimpleX privát", - "simplex-network-section-header": "SimpleX hálózat", - "simplex-network-section-desc": "A Simplex Chat a P2P és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", + "simplex-network-section-header": "SimpleX-hálózat", + "simplex-network-section-desc": "A Simplex Chat a P2P- és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", - "simplex-network-2-desc": "A SimpleX-közvetítő-kiszolgálók NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM csatlakoznak egymáshoz, és NINCS kiszolgálókönyvtár.", - "simplex-network-3-header": "SimpleX hálózat", + "simplex-network-2-desc": "A SimpleX továbbítókiszolgálói NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM kapcsolódnak egymáshoz, és NINCS kiszolgálókönyvtár.", + "simplex-network-3-header": "SimpleX-hálózat", "simplex-network-3-desc": "a kiszolgálók egyirányú üzenet várakoztatásokat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", "comparison-section-header": "Összehasonlítás más protokollokkal", "protocol-1-text": "Signal, nagy platformok", "protocol-2-text": "XMPP, Matrix", - "protocol-3-text": "P2P protokollok", + "protocol-3-text": "P2P-protokollok", "comparison-point-1-text": "Globális személyazonosságot igényel", "comparison-point-2-text": "MITM lehetősége", "comparison-point-4-text": "Egyetlen vagy központosított hálózat", @@ -192,28 +191,28 @@ "comparison-section-list-point-1": "Általában telefonszám alapján, néhány esetben felhasználónév alapján", "comparison-section-list-point-2": "DNS-alapú címek", "comparison-section-list-point-3": "Nyilvános kulcs vagy más globális egyedi azonosító", - "comparison-section-list-point-4a": "A SimpleX-közvetítő-kiszolgálók nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", + "comparison-section-list-point-4a": "A SimpleX továbbítókiszolgálói nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", "comparison-section-list-point-4": "Ha az üzemeltetett kiszolgálók veszélybe kerülnek. Hitelesítse a biztonsági kódot a Signal vagy más biztonságos üzenetküldő alkalmazás segítségével a támadások veszélyeinek csökkentésére", "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", - "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált - egyetlen hálózatként működnek", + "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált — egyetlen hálózatként működnek", "comparison-section-list-point-7": "A P2P-hálózatoknak vagy van egy központi hitelesítője, vagy az egész hálózat kompromittálódhat", - "see-here": "lásd itt", + "see-here": "tekintse meg itt", "guide-dropdown-1": "Gyors indítás", "guide-dropdown-2": "Üzenetek küldése", "guide-dropdown-3": "Titkos csoportok", - "guide-dropdown-4": "Csevegő profilok", + "guide-dropdown-4": "Csevegési profilok", "guide-dropdown-5": "Adatkezelés", - "guide-dropdown-6": "Hang- és videó hívások", + "guide-dropdown-6": "Hang- és videóhívások", "guide-dropdown-7": "Adatvédelem és biztonság", "guide-dropdown-8": "Alkalmazás beállításai", "guide": "Útmutató", - "docs-dropdown-1": "SimpleX platform", + "docs-dropdown-1": "SimpleX-hálózat", "docs-dropdown-2": "Android fájlok elérése", - "docs-dropdown-3": "Hozzáférés a csevegő adatbázishoz", - "docs-dropdown-8": "SimpleX jegyzék szolgáltatás", + "docs-dropdown-3": "Hozzáférés a csevegési adatbázishoz", + "docs-dropdown-8": "SimpleX jegyzékszolgáltatás", "docs-dropdown-9": "Letöltések", - "f-droid-page-simplex-chat-repo-section-text": "Ha hozzá szeretné adni az F-Droid klienséhez, olvassa be a QR-kódot, vagy használja ezt az URL-t:", - "signing-key-fingerprint": "Aláíró kulcs ujjlenyomata (SHA-256)", + "f-droid-page-simplex-chat-repo-section-text": "Ha hozzá szeretné adni az F-Droid klienséhez, olvassa be a QR-kódot, vagy használja ezt a webcímet:", + "signing-key-fingerprint": "Az aláírókulcs ujjlenyomata (SHA-256)", "f-droid-org-repo": "F-Droid.org tároló", "stable-versions-built-by-f-droid-org": "F-Droid.org által készített stabil verziók", "releases-to-this-repo-are-done-1-2-days-later": "A kiadások ebben a tárolóban néhány napot késnek", @@ -221,21 +220,21 @@ "jobs": "Csatlakozzon a csapathoz", "please-enable-javascript": "Engedélyezze a JavaScriptet a QR-kód megjelenítéséhez.", "please-use-link-in-mobile-app": "Használja a mobilalkalmazásban található hivatkozást", - "contact-hero-header": "Kapott egy címet a SimpleX Chat-en való kapcsolódáshoz", - "invitation-hero-header": "Kapott egy egyszer használatos hivatkozást a SimpleX Chat-en való kapcsolódáshoz", - "simplex-network-overlay-card-1-li-4": "A P2P-megvalósításokat egyes internetszolgáltatók blokkolhatják (mint például a BitTorrent). A SimpleX átvitel-független - a szabványos webes protokollokon, pl. WebSocketsen keresztül is működik.", - "simplex-private-card-4-point-2": "A SimpleX TORon keresztüli használatához telepítse az Orbot alkalmazást és engedélyezze a SOCKS5 proxyt (vagy a VPN-t az iOS-ban).", - "simplex-private-card-5-point-1": "A SimpleX minden titkosítási réteghez tartalomkitöltést használ, hogy meghiúsítsa az üzenetméret ellen irányuló támadásokat.", + "contact-hero-header": "Kapott egy meghívót a SimpleX Chaten való beszélgetéshez", + "invitation-hero-header": "Kapott egy egyszer használható meghívót a SimpleX Chaten való beszélgetéshez", + "simplex-network-overlay-card-1-li-4": "A P2P-megvalósításokat egyes internetszolgáltatók blokkolhatják (mint például a BitTorrent). A SimpleX átvitel-független — a szabványos webes protokollokon, például WebSocketsen keresztül is működik.", + "simplex-private-card-4-point-2": "A SimpleX, Tor-hálózaton keresztüli használatához telepítse az Orbot alkalmazást és engedélyezze a SOCKS5 proxyt (vagy a VPN-t az iOS-ban).", + "simplex-private-card-5-point-1": "A SimpleX minden titkosítási réteghez tartalomkitöltést használ az üzenetméretre irányuló támadások meghiúsítása érdekében.", "simplex-private-card-5-point-2": "A kiszolgálók és a hálózatot megfigyelők számára a különböző méretű üzenetek egyformának tűnnek.", "privacy-matters-1-title": "Hirdetés és árdiszkrimináció", "hero-overlay-card-1-p-3": "Ön határozza meg, hogy melyik kiszolgáló(ka)t használja az üzenetek fogadására, a kapcsolatokhoz — azokat a kiszolgálókat, amelyeket az üzenetek küldésére használ. Minden beszélgetés két különböző kiszolgálót használ.", "simplex-network-overlay-card-1-p-1": "A P2P üzenetküldő protokollok és alkalmazások számos problémával küzdenek, amelyek miatt kevésbé megbízhatóak, mint a SimpleX, bonyolultabb az elemzésük és többféle támadással szemben sebezhetőek.", - "chat-bot-example": "Chat bot példa", + "chat-bot-example": "Példa csevegési botra", "simplex-private-3-title": "Biztonságos, hitelesített
TLS adatátvitel", - "github-repository": "GitHub tároló", - "tap-to-close": "Koppintson a bezáráshoz", - "simplex-network-1-header": "A P2P hálózatokkal ellentétben", - "simplex-network-1-overlay-linktext": "a P2P hálózat problémái", + "github-repository": "GitHub tárolójában", + "tap-to-close": "Bezárás", + "simplex-network-1-header": "A P2P-hálózatokkal ellentétben", + "simplex-network-1-overlay-linktext": "a P2P-hálózatok problémái", "comparison-point-3-text": "Függés a DNS-től", "yes": "Igen", "guide-dropdown-9": "Kapcsolatok létrehozása", @@ -253,8 +252,8 @@ "back-to-top": "Vissza a tetejére", "glossary": "Fogalomtár", "simplex-chat-via-f-droid": "SimpleX Chat az F-Droidon keresztül", - "simplex-chat-repo": "SimpleX Chat tároló", + "simplex-chat-repo": "A SimpleX Chat tárolója", "stable-and-beta-versions-built-by-developers": "A fejlesztők által készített stabil és béta verziók", - "hero-overlay-card-3-p-3": "A Trail of Bits 2024 júliusában felülvizsgálta a SimpleX hálózati protokollok kriptográfiai felépítését. Tudjon meg többet.", + "hero-overlay-card-3-p-3": "A Trail of Bits 2024 júliusában ismét auditálta a SimpleX-protokollok kriptográfiai és hálózati komponenseit. További információk.", "docs-dropdown-14": "SimpleX üzleti célra" } diff --git a/website/langs/it.json b/website/langs/it.json index 502ab6d886..18a47bfe9f 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -10,7 +10,7 @@ "simplex-explained-tab-3-p-1": "I server hanno credenziali anonime separate per ogni coda e non sanno a quali utenti appartengano.", "chat-protocol": "Protocollo di chat", "donate": "Dona", - "copyright-label": "© 2020-2024 SimpleX | Progetto Open-Source", + "copyright-label": "© 2020-2025 SimpleX | Progetto Open-Source", "simplex-chat-protocol": "Protocollo di SimpleX Chat", "terminal-cli": "Terminale CLI", "terms-and-privacy-policy": "Informativa sulla privacy", @@ -60,30 +60,29 @@ "hero-overlay-card-2-p-2": "Potrebbero quindi correlare queste informazioni con i social network pubblici esistenti e determinare alcune identità reali.", "hero-overlay-card-2-p-4": "SimpleX protegge da questi attacchi non avendo alcun ID utente per design. Inoltre, se usi la modalità di modalità in incognito, il tuo nome mostrato sarà diverso per ogni contatto, evitando dati condivisi tra di loro.", "simplex-network-overlay-card-1-li-2": "Il design di SimpleX, a differenza della maggior parte delle reti P2P, non ha identificatori utente globali di alcun tipo, nemmeno temporanei, e usa solo identificatori temporanei a coppie, garantendo una maggiore protezione dell'anonimato e dei metadati.", - "simplex-network-overlay-card-1-li-4": "Le implementazioni P2P possono essere bloccate da alcuni fornitori di internet (come BitTorrent). SimpleX è indipendente dal trasporto - può funzionare su protocolli web standard, es. WebSocket.", + "simplex-network-overlay-card-1-li-4": "Le implementazioni P2P possono essere bloccate da alcuni fornitori di internet (come BitTorrent). SimpleX è indipendente dal trasporto — può funzionare su protocolli web standard, es. WebSocket.", "hero-overlay-card-2-p-1": "Quando gli utenti hanno identità permanenti, anche se si tratta solo di un numero casuale, come un Session ID, c'è il rischio che il fornitore o un malintenzionato possano osservare come gli utenti sono connessi e quanti messaggi inviano.", "simplex-network-overlay-card-1-li-6": "Le reti P2P possono essere vulnerabili all'attacco DRDoS, quando i client possono ritrasmettere e amplificare il traffico, con conseguente \"denial of service\" a livello di rete. I client SimpleX si limitano a inoltrare il traffico da una connessione nota e non possono essere usati da un aggressore per amplificare il traffico nell'intera rete.", - "privacy-matters-overlay-card-1-p-4": "La piattaforma SimpleX protegge la privacy delle tue connessioni meglio di qualsiasi alternativa, impedendo completamente che il tuo grafico sociale sia disponibile a qualsiasi azienda o organizzazione. Anche quando le persone usano i server forniti da SimpleX Chat, non conosciamo il numero di utenti o le loro connessioni.", - "privacy-matters-overlay-card-2-p-3": "SimpleX è la prima piattaforma che non ha alcun identificatore utente per design, proteggendo così il tuo grafico delle connessioni meglio di qualsiasi alternativa conosciuta.", + "privacy-matters-overlay-card-1-p-4": "La rete di SimpleX protegge la privacy delle tue connessioni meglio di qualsiasi alternativa, impedendo completamente che il tuo grafico sociale sia disponibile a qualsiasi azienda o organizzazione. Anche quando le persone usano i server preconfigurati in SimpleX Chat, gli operatori dei server non conoscono il numero di utenti o le loro connessioni.", + "privacy-matters-overlay-card-2-p-3": "SimpleX è la prima rete che non ha alcun identificatore utente per design, proteggendo così il tuo grafico delle connessioni meglio di qualsiasi alternativa conosciuta.", "privacy-matters-overlay-card-3-p-1": "Tutti dovrebbero preoccuparsi della privacy e della sicurezza delle proprie comunicazioni — conversazioni innocue possono metterti in pericolo, anche se non hai nulla da nascondere.", "privacy-matters-overlay-card-3-p-3": "Le persone comuni vengono arrestate per ciò che condividono online, anche tramite i loro account \"anonimi\", anche nei Paesi democratici.", - "simplex-unique-overlay-card-1-p-3": "Questo design protegge la privacy di chi stai comunicando, nascondendola ai server della piattaforma SimpleX e a qualsiasi osservatore. Per nascondere il tuo indirizzo IP ai server, puoi connetterti ai server SimpleX tramite Tor.", - "simplex-unique-overlay-card-2-p-1": "Poiché non hai alcun identificatore sulla piattaforma SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come un codice QR o un link.", + "simplex-unique-overlay-card-1-p-3": "Questo design protegge la privacy di chi stai comunicando, nascondendola ai server della rete SimpleX e a qualsiasi osservatore. Per nascondere il tuo indirizzo IP ai server, puoi connetterti ai server SimpleX tramite Tor.", + "simplex-unique-overlay-card-2-p-1": "Poiché non hai alcun identificatore sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come un codice QR o un link.", "simplex-unique-overlay-card-2-p-2": "Anche l'indirizzo utente opzionale, che può essere usato per inviare richieste di contatto spam, è possibile modificarlo o eliminarlo completamente senza perdere alcuna connessione.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat conserva tutti i dati utente solo sui dispositivi client usando un formato trasferibile di database crittografato che può essere esportato e trasferito su qualsiasi dispositivo supportato.", "simplex-unique-overlay-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", - "simplex-unique-overlay-card-4-p-1": "Puoi usare SimpleX con i tuoi server personali e continuare a comunicare con le persone che usano i server preconfigurati forniti da noi.", - "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la piattaforma SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", - "simplex-unique-card-1-p-1": "SimpleX protegge la privacy del tuo profilo, contatti e metadati, nascondendoli ai server della piattaforma SimpleX e ad eventuali osservatori.", - "simplex-unique-card-1-p-2": "A differenza di qualsiasi altra piattaforma di messaggistica esistente, SimpleX non ha identificatori assegnati agli utenti — nemmeno numeri casuali.", - "simplex-unique-card-2-p-1": "Poiché non hai un identificatore o un indirizzo fisso sulla piattaforma SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come codice un QR o un link.", + "simplex-unique-overlay-card-4-p-1": "Puoi usare SimpleX con i tuoi server personali e continuare a comunicare con le persone che usano i server preconfigurati nelle app.", + "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la rete SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", + "simplex-unique-card-1-p-1": "SimpleX protegge la privacy del tuo profilo, contatti e metadati, nascondendoli ai server della rete SimpleX e ad eventuali osservatori.", + "simplex-unique-card-1-p-2": "A differenza di qualsiasi altra rete di messaggistica esistente, SimpleX non ha identificatori assegnati agli utenti — nemmeno numeri casuali.", + "simplex-unique-card-2-p-1": "Poiché non hai un identificatore o un indirizzo fisso sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come codice un QR o un link.", "simplex-unique-card-3-p-1": "SimpleX conserva tutti i dati utente sui dispositivi client in un formato trasferibile di database crittografato — può essere trasferito su un altro dispositivo.", "join": "Unisciti a", "we-invite-you-to-join-the-conversation": "Ti invitiamo a unirti alla conversazione", "enter-your-email-address": "Inserisci il tuo indirizzo email", "get-simplex": "Ottieni SimpleX desktop app", - "why-simplex-is": "Perché SimpleX è", - "unique": "unico", + "why-simplex-is-unique": "Perché SimpleX è unico", "learn-more": "Maggiori informazioni", "more-info": "Mostra info", "hide-info": "Nascondi info", @@ -103,7 +102,7 @@ "use-this-command": "Usa questo comando:", "see-simplex-chat": "Vedi SimpleX Chat", "github-repository": "Repository GitHub", - "the-instructions--source-code": "le istruzioni su come scaricarlo o compilarlo dal codice sorgente.", + "the-instructions--source-code": "per le istruzioni su come scaricarlo o compilarlo dal codice sorgente.", "if-you-already-installed-simplex-chat-for-the-terminal": "Se hai già installato SimpleX Chat per il terminale", "if-you-already-installed": "Se hai già installato", "privacy-matters-section-subheader": "Preservare la privacy dei tuoi metadati — con chi parli — ti protegge da:", @@ -131,13 +130,13 @@ "no-federated": "No - federato", "comparison-section-list-point-1": "Solitamente si basa su un numero di telefono, in alcuni casi su nomi utente", "comparison-section-list-point-2": "Indirizzi basati su DNS", - "comparison-section-list-point-6": "Sebbene i P2P siano distribuiti, non sono federati - operano come un'unica rete", + "comparison-section-list-point-6": "Sebbene i P2P siano distribuiti, non sono federati — operano come un'unica rete", "comparison-section-list-point-7": "Le reti P2P hanno un'autorità centrale o l'intera rete può essere compromessa", "see-here": "vedi qui", "simplex-network-3-header": "Nella rete di SimpleX", "simplex-private-card-7-point-2": "Se un messaggio viene aggiunto, rimosso o modificato, il destinatario verrà avvisato.", - "hero-overlay-card-1-p-2": "Per consegnare i messaggi, invece degli ID utente utilizzati da tutte le altre piattaforme, SimpleX usa identificatori temporanei anonimi a coppie delle code di messaggi, separati per ciascuna delle tue connessioni — non ci sono identificatori a lungo termine.", - "simplex-private-card-6-point-1": "Molte piattaforme di comunicazione sono vulnerabili agli attacchi MITM da parte di server o fornitori di rete.", + "hero-overlay-card-1-p-2": "Per consegnare i messaggi, invece degli ID utente utilizzati da tutte le altre reti, SimpleX usa identificatori temporanei anonimi a coppie delle code di messaggi, separati per ciascuna delle tue connessioni — non ci sono identificatori a lungo termine.", + "simplex-private-card-6-point-1": "Molte reti di comunicazione sono vulnerabili agli attacchi MITM da parte di server o fornitori di rete.", "simplex-private-card-7-point-1": "Per garantire l'integrità, i messaggi sono numerati in sequenza e includono l'hash del messaggio precedente.", "simplex-private-card-9-point-2": "Riduce i vettori di attacco, rispetto ai broker di messaggi tradizionali, e i metadati disponibili.", "simplex-private-card-10-point-2": "Ciò consente di recapitare messaggi senza identificatori del profilo utente, garantendo una migliore privacy dei metadati rispetto alle alternative.", @@ -162,7 +161,7 @@ "feature-3-title": "Gruppi decentralizzati crittografati E2E — solo gli utenti sanno che esistono", "blog": "Blog", "simplex-explained-tab-2-p-1": "Per ogni connessione usi due code di messaggi distinte per inviare e ricevere i messaggi attraverso server diversi.", - "simplex-explained-tab-2-p-2": "I server passano i messaggi solo in una direzione, senza avere il quadro completo della conversazione dell'utente o delle connessioni.", + "simplex-explained-tab-2-p-2": "I server passano i messaggi solo in una direzione, senza avere il quadro completo delle conversazioni dell'utente o delle connessioni.", "simplex-explained-tab-3-p-2": "Gli utenti possono aumentare ulteriormente la privacy dei metadati usando Tor per accedere ai server, evitando correlazioni per indirizzo IP.", "smp-protocol": "Protocollo SMP", "feature-1-title": "Messaggi crittografati E2E con markdown e modifica", @@ -182,21 +181,21 @@ "privacy-matters-overlay-card-1-p-2": "I rivenditori online sanno che le persone con redditi più bassi sono più propense a fare acquisti urgenti, quindi possono applicare prezzi più alti o rimuovere sconti.", "privacy-matters-overlay-card-1-p-3": "Alcune società finanziarie e assicurative usano grafici sociali per determinare i tassi di interesse e i premi. Spesso ciò fa pagare di più le persone con redditi più bassi — è noto come \"premio di povertà\" .", "privacy-matters-overlay-card-2-p-1": "Non molto tempo fa abbiamo assistito alla manipolazione delle principali elezioni da una rispettabile società di consulenza che ha usato i nostri grafici sociali per distorcere la nostra visione del mondo reale e manipolare i nostri voti.", - "privacy-matters-overlay-card-2-p-2": "Per essere obiettivi e prendere decisioni indipendenti devi avere il controllo del tuo spazio informativo. È possibile solo se utilizzi una piattaforma di comunicazione privata che non ha accesso al tuo grafico sociale.", + "privacy-matters-overlay-card-2-p-2": "Per essere obiettivi e prendere decisioni indipendenti devi avere il controllo del tuo spazio informativo. È possibile solo se utilizzi una rete di comunicazione privata che non ha accesso al tuo grafico sociale.", "privacy-matters-overlay-card-3-p-2": "Una delle storie più scioccanti è l'esperienza di Mohamedou Ould Salahi descritta nel suo libro di memorie e mostrata nel film The Mauritanian. È stato rinchiuso nel campo di Guantánamo, senza processo, e lì è stato torturato per 15 anni dopo una telefonata a un suo parente in Afghanistan, sospettato di essere coinvolto negli attacchi dell'11/9, nonostante avesse vissuto in Germania per i precedenti 10 anni.", "join-us-on-GitHub": "Unisciti a noi su GitHub", "simplex-chat-for-the-terminal": "SimpleX Chat per il terminale", "privacy-matters-overlay-card-3-p-4": "Non è sufficiente usare un messenger crittografato end-to-end, tutti dovremmo usare i messenger che proteggono la privacy delle nostre reti personali — con chi siamo connessi.", - "simplex-unique-overlay-card-1-p-2": "Per recapitare i messaggi, SimpleX usa indirizzi anonimi a coppie di code di messaggi unidirezionali, separate per i messaggi ricevuti e inviati, di solito tramite server diversi. Usare SimpleX è come avere un'email o telefono “temporanei” per ogni contatto, e nessuna seccatura per gestirli.", - "simplex-unique-overlay-card-1-p-1": "A differenza di altre piattaforme di messaggistica, SimpleX non ha alcun identificatore assegnato agli utenti. Non si basa su numeri di telefono, indirizzi basati su domini (come email o XMPP), nomi utente, chiavi pubbliche o persino numeri casuali per identificare i suoi utenti — non sappiamo quante persone usano i nostri server SimpleX.", + "simplex-unique-overlay-card-1-p-2": "Per recapitare i messaggi, SimpleX usa indirizzi anonimi a coppie di code di messaggi unidirezionali, separate per i messaggi ricevuti e inviati, di solito tramite server diversi.", + "simplex-unique-overlay-card-1-p-1": "A differenza di altre reti di messaggistica, SimpleX non ha alcun identificatore assegnato agli utenti. Non si basa su numeri di telefono, indirizzi basati su domini (come email o XMPP), nomi utente, chiavi pubbliche o persino numeri casuali per identificare i suoi utenti — gli operatori dei server non sanno quante persone usano i loro server.", "simplex-unique-overlay-card-3-p-3": "A differenza dei server di reti federate (email, XMPP o Matrix), i server SimpleX non conservano gli account utente, ma trasmettono solo i messaggi, proteggendo la privacy di entrambe le parti.", "simplex-unique-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", "simplex-unique-card-4-p-2": "Puoi usare SimpleX con i tuoi server personali o con i server forniti da noi — e connetterti comunque a qualsiasi utente.", "join-the-REDDIT-community": "Unisciti alla comunità di REDDIT", "sign-up-to-receive-our-updates": "Iscriviti per ricevere i nostri aggiornamenti", "simplex-unique-overlay-card-3-p-4": "Non ci sono identificatori o testi cifrati in comune tra il traffico del server inviato e quello ricevuto — se qualcuno lo osserva, non può determinare facilmente chi comunica con chi, anche se il TLS è compromesso.", - "simplex-unique-overlay-card-4-p-2": "La piattaforma SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX puoi creare.", - "simplex-unique-card-4-p-1": "La rete SimpleX è completamente decentralizzata e indipendente da qualsiasi criptovaluta o altra piattaforma, ad eccezione di internet.", + "simplex-unique-overlay-card-4-p-2": "La rete di SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX creerai.", + "simplex-unique-card-4-p-1": "La rete SimpleX è completamente decentralizzata e indipendente da qualsiasi criptovaluta o altra rete, ad eccezione di internet.", "donate-here-to-help-us": "Dona qui per aiutarci", "to-make-a-connection": "Per stabilire una connessione:", "scan-the-qr-code-with-the-simplex-chat-app-description": "Le chiavi pubbliche e l'indirizzo della coda di messaggi in questo link NON vengono inviati in rete quando vedi questa pagina —
sono contenuti nel frammento hash dell'URL del link.", @@ -219,7 +218,7 @@ "guide-dropdown-7": "Privacy e sicurezza", "guide-dropdown-9": "Creare connessioni", "guide": "Guida", - "docs-dropdown-1": "Piattaforma SimpleX", + "docs-dropdown-1": "Rete di SimpleX", "docs-dropdown-2": "Accesso ai file Android", "docs-dropdown-3": "Accesso al database di chat", "docs-dropdown-4": "Ospita un server SMP", diff --git a/website/langs/ja.json b/website/langs/ja.json index 6f994b59da..05365e2271 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -52,7 +52,7 @@ "chat-protocol": "チャットプロトコル", "chat-bot-example": "チャットボットの例", "donate": "寄付", - "copyright-label": "© 2020-2024 SimpleX | Open-Source Project", + "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", "hero-p-1": "他のアプリにはユーザー ID があります: Signal、Matrix、Session、Briar、Jami、Cwtch など。
SimpleX にはありません。乱数さえもありません
これにより、プライバシーが大幅に向上します。", "copy-the-command-below-text": "以下のコマンドをコピーしてチャットで使用します:", "simplex-private-card-9-point-1": "各メッセージ キューは、異なる送信アドレスと受信アドレスを使用してメッセージを一方向に渡します。", @@ -193,13 +193,12 @@ "simplex-unique-overlay-card-3-p-2": "エンドツーエンドで暗号化されたメッセージは、SimpleXのリレーサーバーで受信するまで一時的に保持され、その後永久に削除されます。", "simplex-private-card-7-point-1": "整合性を保証するために、メッセージには連続した番号が付けられ、前のメッセージのハッシュが含まれます。", "contact-hero-p-2": "SimpleX Chat をまだダウンロードしていませんか?", - "why-simplex-is": "なぜSimpleXなのか", + "why-simplex-is-unique": "なぜSimpleXなのか唯一", "simplex-network-section-header": "SimpleX ネットワーク", "simplex-private-10-title": "一時的な匿名のペア識別子", "privacy-matters-1-overlay-1-linkText": "プライバシーの保護はコストを削減します", "tap-the-connect-button-in-the-app": "アプリの 「接続」 ボタンをタップします", "comparison-section-list-point-4a": "SimpleX リレーは e2e 暗号化を侵害できません。 セキュリティ コードを検証して帯域外チャネルへの攻撃を軽減します", - "unique": "唯一", "simplex-network-1-overlay-linktext": "P2Pネットワークの問題点", "no-private": "いいえ - プライベート", "simplex-unique-1-title": "プライバシーが完全に守られます", @@ -246,13 +245,15 @@ "simplex-private-5-title": "何レイヤーもの
コンテンツパディング", "hero-overlay-card-3-p-1": "Trail of Bitsは、大手ハイテク企業、政府機関、主要なブロックチェーン・プロジェクトなどを顧客に持つ、セキュリティとテクノロジーの大手コンサルタント会社です。", "jobs": "チームに参加する", - "hero-overlay-3-textlink": "セキュリティ監査", - "hero-overlay-3-title": "セキュリティ監査", + "hero-overlay-3-textlink": "セキュリティ評価", + "hero-overlay-3-title": "セキュリティ評価", "hero-overlay-card-3-p-2": "Trail of Bitsは2022年11月にSimpleXプラットフォームの暗号とネットワークのコンポーネントを検証しました。詳しくは お知らせをご覧ください。", "docs-dropdown-9": "ダウンロード", "please-enable-javascript": "QRコードを表示するためにJavaScriptを有効にしてください。", "please-use-link-in-mobile-app": "このリンクをモバイルアプリで使用してください", "docs-dropdown-10": "透明性", "docs-dropdown-11": "よくある質問", - "docs-dropdown-12": "セキュリティ" + "docs-dropdown-12": "セキュリティ", + "docs-dropdown-14": "ビジネス向けSimpleX", + "hero-overlay-card-3-p-3": "Trail of Bits は 2024 年 7 月に SimpleX ネットワーク プロトコルの暗号設計をレビューしました。" } diff --git a/website/langs/nl.json b/website/langs/nl.json index c725cc3d3e..30e751c9f9 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -7,7 +7,7 @@ "why-simplex": "Waarom SimpleX", "simplex-privacy": "SimpleX privacy", "simplex-network": "SimpleX netwerk", - "simplex-explained": "Simplex uitgelegd", + "simplex-explained": "SimpleX uitgelegd", "simplex-explained-tab-1-text": "1. Wat gebruikers ervaren", "simplex-explained-tab-3-text": "3. Wat servers zien", "simplex-explained-tab-1-p-1": "U kunt contacten en groepen maken en tweerichtings gesprekken voeren, zoals in elke andere messenger.", @@ -17,7 +17,7 @@ "chat-bot-example": "Chatbot voorbeeld", "smp-protocol": "SMP protocol", "donate": "Doneer", - "copyright-label": "© 2020-2024 SimpleX | Open-sourceproject", + "copyright-label": "© 2020-2025 SimpleX | Open-sourceproject", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Privacybeleid", @@ -116,8 +116,7 @@ "join-us-on-GitHub": "Volg ons op GitHub", "donate-here-to-help-us": "Doneer hier om ons te helpen", "sign-up-to-receive-our-updates": "Schrijf u in om onze updates te ontvangen", - "unique": "uniek", - "why-simplex-is": "Waarom is SimpleX", + "why-simplex-is-unique": "Waarom is SimpleX uniek", "learn-more": "Kom meer te weten", "more-info": "Meer informatie", "hide-info": "Info verbergen", @@ -183,7 +182,7 @@ "privacy-matters-overlay-card-3-p-2": "Een van de meest schokkende verhalen is de ervaring van Mohamedou Ould Salahi beschreven in zijn memoires en getoond in The Mauritanian movie. Hij werd zonder proces in het Guantanamo kamp geplaatst en werd daar 15 jaar lang gemarteld na een telefoontje naar zijn familielid in Afghanistan, op verdenking van betrokkenheid bij aanslagen van 9/11, ook al woonde hij de afgelopen 10 jaar in Duitsland.", "privacy-matters-overlay-card-3-p-3": "Gewone mensen worden gearresteerd voor wat ze online delen, zelfs via hun 'anonieme' accounts, zelfs in democratische landen.", "privacy-matters-overlay-card-3-p-4": "Het is niet genoeg om een end-to-end versleutelde messenger te gebruiken, we zouden allemaal de messengers moeten gebruiken die de privacy van onze persoonlijke netwerken beschermen — met wie we verbonden zijn.", - "simplex-unique-overlay-card-1-p-2": "Om berichten af te leveren gebruikt SimpleX paarsgewijze anonieme adressen van unidirectionele berichten wachtrijen, gescheiden voor ontvangen en verzonden berichten, meestal via verschillende servers. Het gebruik van SimpleX is als het hebben van een andere “brander” e-mail of telefoon voor elk contact, en geen gedoe om ze te beheren.", + "simplex-unique-overlay-card-1-p-2": "Om berichten af te leveren gebruikt SimpleX paarsgewijze anonieme adressen van unidirectionele berichten wachtrijen, gescheiden voor ontvangen en verzonden berichten, meestal via verschillende servers.", "simplex-unique-overlay-card-1-p-3": "Dit ontwerp beschermt de privacy van met wie u communiceert en verbergt deze voor SimpleX platform servers en voor waarnemers. Om uw IP-adres voor de servers te verbergen, kunt u verbinding maken met SimpleX servers via Tor .", "simplex-unique-overlay-card-2-p-1": "Omdat je geen identificatie hebt op het SimpleX platform, kan niemand contact met je opnemen, tenzij je een eenmalig of tijdelijk gebruikers adres deelt, als een QR-code of een link.", "simplex-unique-overlay-card-2-p-2": "Zelfs met het optionele gebruikers adres, hoewel het kan worden gebruikt om spam-contact verzoeken te verzenden, kunt u het wijzigen of volledig verwijderen zonder uw verbindingen te verliezen.", @@ -255,5 +254,6 @@ "docs-dropdown-10": "Transparantie", "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Beveiliging", - "hero-overlay-card-3-p-3": "Trail of Bits heeft in juli 2024 het cryptografische ontwerp van SimpleX-netwerkprotocollen beoordeeld. Lees meer." + "hero-overlay-card-3-p-3": "Trail of Bits heeft in juli 2024 het cryptografische ontwerp van SimpleX-netwerkprotocollen beoordeeld. Lees meer.", + "docs-dropdown-14": "SimpleX voor bedrijven" } diff --git a/website/langs/pl.json b/website/langs/pl.json index d0674e3d8a..d45207a709 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -15,7 +15,7 @@ "smp-protocol": "Protokół SMP", "chat-protocol": "Protokół czatu", "donate": "Darowizna", - "copyright-label": "© 2020-2024 SimpleX | Projekt Open-Source", + "copyright-label": "© 2020-2025 SimpleX | Projekt Open-Source", "simplex-chat-protocol": "Protokół SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Polityka prywatności", @@ -140,7 +140,7 @@ "simplex-network-overlay-card-1-li-6": "Sieci P2P mogą być podatne na atak DRDoS, kiedy klienci mogą rozgłaszać i wzmacniać ruch, co powoduje odmowę usługi w całej sieci. Klienci SimpleX przekazują jedynie ruch ze znanego połączenia i nie mogą być wykorzystani przez atakującego do wzmocnienia ruchu w całej sieci.", "privacy-matters-overlay-card-1-p-1": "Wiele dużych firm wykorzystuje informacje o tym, z kim jesteś połączony, aby oszacować Twoje dochody, sprzedać Ci produkty, których tak naprawdę nie potrzebujesz, oraz ustalić ceny.", "simplex-unique-overlay-card-2-p-2": "Nawet w przypadku opcjonalnego adresu użytkownika, podczas gdy może on być używany do wysyłania spamowych zapytań o kontakt, możesz go zmienić lub całkowicie usunąć bez utraty jakichkolwiek połączeń.", - "simplex-unique-overlay-card-1-p-2": "Do dostarczania wiadomości SimpleX używa parami anonimowych adresów jednokierunkowych kolejek z wiadomościami, oddzielnych dla wiadomości odbieranych i wysyłanych, zwykle przez różne serwery. Korzystanie z SimpleX jest jak posiadanie wielu “jednorazowych” emailów lub osobnego telefonu dla każdego kontaktu i braku problemów z zarządzaniem nimi.", + "simplex-unique-overlay-card-1-p-2": "Do dostarczania wiadomości SimpleX używa parami anonimowych adresów jednokierunkowych kolejek z wiadomościami, oddzielnych dla wiadomości odbieranych i wysyłanych, zwykle przez różne serwery.", "simplex-unique-overlay-card-1-p-3": "Taka konstrukcja chroni prywatność tego, z kim się komunikujesz, ukrywając ją przed serwerami platformy SimpleX i przed wszelkimi obserwatorami. Aby ukryć swój adres IP przed serwerami, możesz połączyć się z serwerami SimpleX za pośrednictwem sieci Tor.", "simplex-unique-overlay-card-3-p-4": "Nie ma żadnych identyfikatorów ani szyfrogramów wspólnych między wysyłanym i odbieranym ruchem serwera — jeśli ktokolwiek to obserwuje, nie może łatwo określić, kto komunikuje się z kim, nawet jeśli bezpieczeństwo protokołu TLS zostało zagrożone.", "simplex-unique-overlay-card-2-p-1": "Ponieważ nie masz identyfikatora na platformie SimpleX, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku.", @@ -154,13 +154,12 @@ "simplex-unique-card-1-p-2": "W przeciwieństwie do każdej innej istniejącej platformy komunikacyjnej, SimpleX nie ma żadnych identyfikatorów przypisanych do użytkowników — nawet losowych liczb.", "tap-the-connect-button-in-the-app": "Stuknij przycisk 'połącz się' w aplikacji", "join-the-REDDIT-community": "Dołącz do społeczności REDDIT", - "unique": "unikalny", "hide-info": "Ukryj informacje", "simplex-unique-card-4-p-2": "Możesz używać SimpleX z własnymi serwerami lub z serwerami dostarczonymi przez nas — i nadal łączyć się z dowolnym użytkownikiem.", "we-invite-you-to-join-the-conversation": "Zapraszamy do udziału w rozmowie", "enter-your-email-address": "Wpisz swój adres e-mail", "get-simplex": "Pobierz SimpleX desktop app", - "why-simplex-is": "Dlaczego SimpleX jest", + "why-simplex-is-unique": "Dlaczego SimpleX jest unikalny", "join": "Dołącz do", "join-us-on-GitHub": "Dołącz do nas na GitHubie", "sign-up-to-receive-our-updates": "Zapisz się, aby otrzymywać nasze aktualizacje", @@ -244,15 +243,17 @@ "stable-versions-built-by-f-droid-org": "Wersje stabilne zbudowane przez F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "Wydania na tym repo są kilka dni później", "comparison-section-list-point-4a": "Przekaźniki SimpleX nie mogą skompromitować szyfrowania e2e. Zweryfikuj kody bezpieczeństwa aby złagodzić atak na kanał pozapasmowy", - "hero-overlay-3-title": "Ocena bezpieczeństwa", + "hero-overlay-3-title": "Oceny bezpieczeństwa", "hero-overlay-card-3-p-2": "Trail of Bits przejrzał komponenty kryptograficzne i sieciowe platformy SimpleX w listopadzie 2022. Przeczytaj więcej w ogłoszeniach.", "jobs": "Dołącz do zespołu", - "hero-overlay-3-textlink": "Ocena bezpieczeństwa", + "hero-overlay-3-textlink": "Analizy bezpieczeństwa", "hero-overlay-card-3-p-1": "Trail of Bits jest wiodącą firmą konsultingową w zakresie bezpieczeństwa i technologii, której klientami są duże firmy technologiczne, agencje rządowe i główne projekty blockchain.", "docs-dropdown-9": "Pliki do pobrania", "please-enable-javascript": "Prosimy o włączenie JavaScript w celu wyświetlenia kodu QR.", "please-use-link-in-mobile-app": "Prosimy o skorzystanie z linku w aplikacji mobilnej", "docs-dropdown-10": "Przezroczystość", "docs-dropdown-12": "Bezpieczeństwo", - "docs-dropdown-11": "Często zadawane pytania" + "docs-dropdown-11": "Często zadawane pytania", + "hero-overlay-card-3-p-3": "Firma Trail of Bits dokonała analizy projektu kryptograficznego protokołów sieciowych SimpleX w lipcu 2024 roku. Dowiedz się więcej.", + "docs-dropdown-14": "SimpleX dla firm" } diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 73095c6db2..77854f5be1 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -11,7 +11,7 @@ "simplex-explained-tab-1-p-1": "Você pode criar contatos e grupos e ter conversas bidirecionais, como em qualquer outro mensageiro.", "simplex-explained-tab-2-p-1": "Para cada conexão, são usadas duas filas de mensagens separadas para enviar e receber mensagens por meio de servidores diferentes.", "hero-p-1": "Outros aplicativos possuem IDs de usuário: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
O SimpleX não tem, nem mesmo números aleatórios.
Isso melhora radicalmente a sua privacidade.", - "terms-and-privacy-policy": "Termos e Política de Privacidade", + "terms-and-privacy-policy": "Termos de serviço & Política de Privacidade", "hero-2-header-desc": "O vídeo mostra como você se conecta com seu amigo através do QR code de uso único dele, pessoalmente ou via link de vídeo. Você também pode se conectar compartilhando um link de convite.", "feature-7-title": "Armazenamento do aplicativo criptografado portátil — mova o perfil para outro dispositivo", "simplex-explained": "Explicação do SimpleX", @@ -25,7 +25,7 @@ "smp-protocol": "Protocolo SMP", "chat-protocol": "Protocolo de bate-papo", "donate": "Doar", - "copyright-label": "© 2020-2024 SimpleX | Projeto de Código Livre", + "copyright-label": "© 2020-2025 SimpleX | Projeto de Código Livre", "simplex-chat-protocol": "Protocolo Chat SimpleX", "terminal-cli": "CLI Terminal", "hero-header": "Privacidade redefinida", @@ -127,7 +127,7 @@ "privacy-matters-overlay-card-2-p-3": "O SimpleX é a primeira plataforma que não tem nenhum identificador de usuário por design, protegendo assim seu gráfico de conexões melhor do que qualquer alternativa conhecida.", "privacy-matters-overlay-card-3-p-3": "Pessoas comuns são presas pelo que compartilham online, mesmo por meio de suas contas \"anônimas\", mesmo em países democráticos.", "privacy-matters-overlay-card-3-p-4": "Não basta usar um mensageiro criptografado de ponta-a-ponta, todos nós devemos usar os mensageiros que protegem a privacidade de nossas redes pessoais — com quem estamos conectados.", - "simplex-unique-overlay-card-1-p-2": "Para entregar mensagens, o SimpleX usa endereços anônimos em pares de filas de mensagens unidirecionais, separadas para mensagens recebidas e enviadas, geralmente por meio de servidores diferentes. Usar o SimpleX é como ter um email ou telefone de “gravação” diferente para cada contato, sem a necessidade de gerenciá-los.", + "simplex-unique-overlay-card-1-p-2": "Para entregar mensagens, o SimpleX usa endereços anônimos em pares de filas de mensagens unidirecionais, separadas para mensagens recebidas e enviadas, geralmente por meio de servidores diferentes.", "simplex-unique-overlay-card-1-p-3": "Esse design protege a privacidade de quem está se comunicando com você, ocultando-a dos servidores da plataforma SimpleX e de quaisquer observadores. Para ocultar seu endereço IP dos servidores, você pode se conectar aos servidores do SimpleX via Tor.", "simplex-unique-overlay-card-2-p-2": "Mesmo com o endereço de usuário opcional, embora ele possa ser usado para enviar solicitações de contato de spam, você pode alterá-lo ou excluí-lo completamente sem perder nenhuma das suas conexões.", "simplex-unique-overlay-card-2-p-1": "Como você não tem um identificador na plataforma SimpleX, ninguém pode entrar em contato com você, a menos que compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", @@ -147,8 +147,7 @@ "sign-up-to-receive-our-updates": "Inscreva-se para receber nossas atualizações", "enter-your-email-address": "Digite seu endereço de email", "get-simplex": "Obtenha o SimpleX aplicativo desktop", - "why-simplex-is": "Por que o SimpleX é", - "unique": "único", + "why-simplex-is-unique": "Por que o SimpleX é único", "learn-more": "Saiba mais", "more-info": "Mais informações", "contact-hero-header": "Você recebeu um endereço para se conectar no SimpleX Chat", @@ -242,9 +241,9 @@ "simplex-chat-repo": "Repositório Simplex Chat", "f-droid-org-repo": "Repositório F-Droid.org", "stable-versions-built-by-f-droid-org": "Versões estáveis criadas por F-Droid.org", - "releases-to-this-repo-are-done-1-2-days-later": "Os lançamentos para este repositório são feitos 1-2 dias depois", - "hero-overlay-3-textlink": "Avaliação Segura", - "hero-overlay-3-title": "Avaliação Segura", + "releases-to-this-repo-are-done-1-2-days-later": "Os lançamentos para este repositório são feitos 1 ou 2 dias depois", + "hero-overlay-3-textlink": "Avaliações de segurança", + "hero-overlay-3-title": "Avaliações de segurança", "hero-overlay-card-3-p-1": "Trail of Bits é uma consultoria líder em segurança e tecnologia cujos clientes incluem grandes empresas de tecnologia, agências governamentais e grandes projetos de blockchain.", "hero-overlay-card-3-p-2": "Trail of Bits analisou a criptografia da plataforma SimpleX e os componentes de rede em novembro de 2022. Leia mais em o anúncio.", "f-droid-page-f-droid-org-repo-section-text": "Os repositórios SimpleX Chat e F-Droid.org assinam compilações com chaves diferentes. Para mudar, exporte o banco de dados de bate-papo e reinstale o aplicativo.", @@ -254,5 +253,7 @@ "docs-dropdown-9": "Baixar", "docs-dropdown-11": "FAQ", "docs-dropdown-10": "Transparência", - "docs-dropdown-12": "Segurança" + "docs-dropdown-12": "Segurança", + "hero-overlay-card-3-p-3": "Trail of Bits revisou o design criptografico das redes utilizadas pelo SimpleX em julho de 2024", + "docs-dropdown-14": "SimpleX para negócios" } diff --git a/website/langs/ru.json b/website/langs/ru.json index 5d59ec2c76..ea7766db1b 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -1,259 +1,259 @@ { "copy-the-command-below-text": "скопируйте приведенную ниже команду и используйте ее в чате:", - "copyright-label": "© 2020-2024 SimpleX | Проект с открытым исходным кодом", + "copyright-label": "© 2020-2025 SimpleX | Проект с открытым исходным кодом", "chat-bot-example": "Пример Чат бота", "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", - "simplex-private-card-1-point-2": "Криптобокс NaCL в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометации TLS.", - "contact-hero-p-1": "Открытые ключи и адрес очереди сообщений по этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", + "simplex-private-card-1-point-2": "NaCL cryptobox в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометированного TLS.", + "contact-hero-p-1": "Публичные ключи и адрес очереди сообщений в этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", "guide-dropdown-5": "Управление данными", "scan-the-qr-code-with-the-simplex-chat-app": "Отсканируйте QR-код с помощью приложения SimpleX Chat", - "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными посредниками для доставки сообщений.", + "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными серверами доставки сообщений.", "simplex-unique-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", - "feature-7-title": "Портативное, зашифрованное хранилище в приложении — можно перенести профиль на другое устройство", - "no-federated": "Нет - федеративный", - "hero-2-header": "Как начать общаться приватно", + "feature-7-title": "Зашифрованное база данных — Вы можете перенести профиль на другое устройство", + "no-federated": "Нет - федеративные", + "hero-2-header": "Установите конфиденциальное соединение", "simplex-unique-overlay-card-3-p-3": "В отличие от серверов федеративных сетей (электронной почты, XMPP или Matrix), серверы SimpleX не хранят учетные записи пользователей, они только ретранслируют сообщения, защищая конфиденциальность обеих сторон.", - "hero-subheader": "Первый мессенджер
не нуждающийся в идентификаторах
пользователя", - "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй - это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистан под подозревается в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", - "signing-key-fingerprint": "Отпечаток ключа подписи (SHA-256)", + "hero-subheader": "Первый мессенджер
без идентификаторов пользователей", + "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", + "signing-key-fingerprint": "Идентификатор ключа подписи (SHA-256)", "simplex-network-2-desc": "Серверные узлы SimpleX НЕ хранят профили пользователей, контакты и доставленные сообщения, НЕ подключаются друг к другу, и НЕ имеют каталога серверов.", "simplex-privacy": "Конфиденциальность SimpleX", "docs-dropdown-5": "Свой XFTP Сервер", - "simplex-private-card-3-point-2": "Отпечаток сервера и привязка канала предотвращают MITM атаки и Атаки повторного воспроизведения.", + "simplex-private-card-3-point-2": "Идентификатор сервера и привязка к TLS сессии предотвращают атаки перехвата (MITM) и повторного использования.", "docs-dropdown-3": "Доступ к в базе данных чата", "installing-simplex-chat-to-terminal": "Установка SimpleX Chat в терминале", "use-this-command": "Используйте эту команду:", - "simplex-explained": "Простое объяснение вкратце", + "simplex-explained": "Как SimpleX работает", "to-make-a-connection": "Чтобы установить соединение:", - "comparison-section-list-point-6": "Хотя P2P распределены, они не являются федеративными, то есть P2P - работают как единая сеть", - "hero-overlay-2-textlink": "Как работает SimpleX?", + "comparison-section-list-point-6": "Хотя P2P распределены, они не являются федеративными, то есть P2P — работают как единая сеть", + "hero-overlay-2-textlink": "Как SimpleX работает?", "simplex-chat-via-f-droid": "SimpleX Chat в F-Droid", - "privacy-matters-overlay-card-1-p-1": "Многие крупные компании используют информацию о том, с кем вы связаны, чтобы оценить ваш доход, продавать вам больше товаров, которые вам на самом деле не нужны, и определять из этой информации, выгодные для них цены.", - "privacy-matters-1-overlay-1-title": "Конфиденциальность экономит ваши деньги", - "simplex-private-card-5-point-1": "SimpleX использует заполнение содержимого для каждого уровня шифрования, чтобы предотвратить атаки на размер сообщения.", - "privacy-matters-2-overlay-1-linkText": "Конфиденциальность дает вам власть", - "hero-overlay-3-title": "Оценка безопасности", - "enter-your-email-address": "Email адрес", - "simplex-explained-tab-1-text": "1. Как это видят пользователи", + "privacy-matters-overlay-card-1-p-1": "Многие крупные компании используют информацию о том, с кем Вы связаны, чтобы оценить Ваш доход, продавать Вам больше товаров, которые Вам на самом деле не нужны, и определять из этой информации, выгодные для них цены.", + "privacy-matters-1-overlay-1-title": "Конфиденциальность экономит Ваши деньги", + "simplex-private-card-5-point-1": "SimpleX использует дополнение содержания для каждого уровня шифрования, чтобы скрыть размер сообщений.", + "privacy-matters-2-overlay-1-linkText": "Конфиденциальность дает Вам власть", + "hero-overlay-3-title": "Аудит безопасности", + "enter-your-email-address": "Введите Ваш адрес Email", + "simplex-explained-tab-1-text": "1. Что видят пользователи", "tap-to-close": "Нажмите, чтобы закрыть", "simplex-unique-card-4-p-2": "Вы можете использовать SimpleX со своими собственными серверами или с серверами, предоставленными нами — и при этом подключаться к любому пользователю SimpleX.", - "hero-overlay-card-3-p-2": "В ноябре 2022 года Trail of Bits провела обзор криптографии и сетевых компонентов SimpleX. Дополнительная информация.", - "hero-overlay-card-3-p-3": "В июле 2024 года Trail of Bits провела обзор криптографического дизайна протоколов SimpleX. Дополнительная информация.", - "feature-1-title": "Сообщения зашифрованные E2E-шифрованием
с поддержкой markdown и редактированием", + "hero-overlay-card-3-p-2": "В ноябре 2022 года Trail of Bits провела аудит криптографии и сетевых компонентов SimpleX. Дополнительная информация.", + "hero-overlay-card-3-p-3": "В июле 2024 года Trail of Bits провела аудит криптографического дизайна протоколов SimpleX. Дополнительная информация.", + "feature-1-title": "Сообщения с E2E-шифрованием
с форматированием и редактированием", "comparison-point-4-text": "Единая или Централизованная сеть", "guide-dropdown-9": "Установление соединений", - "simplex-unique-1-overlay-1-title": "Полная конфиденциальность вашей личности, профиля, контактов и других метаданных", - "hero-overlay-card-2-p-4": "SimpleX защищает от этих атак, поскольку в его конструкции нет никаких идентификаторов пользователей. И, если вы используете режим инкогнито, у вас будет другое отображаемое имя для каждого контакта, что позволит избежать какого-либо пересечения между ними.", - "privacy-matters-overlay-card-2-p-2": "Чтобы быть объективным и принимать независимые решения, вам необходимо контролировать свое информационное пространство. Это возможно только в том случае, если вы используете приватную, коммуникационную платформу, которая не имеет доступа к вашему социальному графу.", - "hero-overlay-card-2-p-1": "Когда у пользователей есть постоянные идентификаторы, даже если это просто случайное число, например идентификатор сеанса, существует риск того, что провайдер или злоумышленник могут наблюдайте за тем, как подключены пользователи и сколько сообщений они отправляют.", - "feature-3-title": "Децентрализованные группы — только
их участники знают, что они существуют", + "simplex-unique-1-overlay-1-title": "Полная конфиденциальность Вашей личности, профиля, контактов и метаданных", + "hero-overlay-card-2-p-4": "SimpleX защищает от этих атак, поскольку он не использует никакие идентификаторы профилей пользователей. И, если Вы используете режим инкогнито, у Вас будет другое отображаемое имя для каждого контакта, что позволит избежать какого-либо пересечения между ними.", + "privacy-matters-overlay-card-2-p-2": "Чтобы быть объективным и принимать независимые решения, необходимо контролировать свое информационное пространство. Это возможно только, если Вы используете конфиденциальную коммуникационную сеть, которая не имеет доступа к контактам Вашей социальной сети.", + "hero-overlay-card-2-p-1": "Когда у пользователя есть постоянный идентификатор, даже если это просто случайное число, например Session ID, существует риск того, что провайдер или злоумышленник могут наблюдать за тем, как пользователи соединены и сколько сообщений они отправляют.", + "feature-3-title": "Децентрализованные группы — известные только участникам", "glossary": "Глоссарий", "simplex-network-overlay-1-title": "Сравнение с протоколами обмена сообщениями P2P", - "comparison-section-list-point-7": "Сети P2P либо имеют центральный орган управления, либо вся сеть может быть скомпрометирована", - "simplex-unique-overlay-card-4-p-1": "Вы можете использовать SimpleX со своими собственными серверами или предоставленными нами серверами, при этом имея возможность общаться с любым пользователем.", - "simplex-explained-tab-3-p-1": "Серверы имеют отдельные, Анонимные учётные данные для каждой очереди и не знают, к каким пользователям они принадлежат.", - "docs-dropdown-1": "Платформа SimpleX", - "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым, Сквозным шифрованием.", + "comparison-section-list-point-7": "Сети P2P либо имеют центральный компонент, либо вся сеть может быть атакована", + "simplex-unique-overlay-card-4-p-1": "Вы можете использовать SimpleX со своими собственными серверами или с серверами предустановленными в приложении, при этом имея возможность общаться с любым пользователем.", + "simplex-explained-tab-3-p-1": "Серверы имеют отдельные, анонимные учётные данные для каждой очереди и не знают, каким пользователям они принадлежат.", + "docs-dropdown-1": "Сеть SimpleX", + "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым сквозным шифрованием.", "simplex-chat-for-the-terminal": "SimpleX Chat для терминала", - "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и большинство существующих реализаций не используют внеполосные сообщения для первоначального обмена ключами. SimpleX использует внеполосные сообщения или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", + "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и многие сети не используют сообщения вне протокола для первоначального обмена ключами. SimpleX использует сообщения вне протокола или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", "the-instructions--source-code": "SimpleX Chat.", "simplex-network-section-desc": "SimpleX Chat обеспечивает наилучшую конфиденциальность, сочетая преимущества P2P и федеративных сетей.", - "privacy-matters-section-subheader": "Сохранение конфиденциальности ваших метаданных — с кем вы общаетесь — защищает вас от:", - "if-you-already-installed": "Если вы уже установили", - "simplex-explained-tab-3-p-2": "Пользователи могут еще больше повысить свою конфиденциальность скрыв свой IP-адрес, например используя сеть Tor для доступа к серверам.", - "join": "Присоединяйся к", - "privacy-matters-section-header": "Почему приватность важна", - "hero-overlay-1-textlink": "Почему идентификаторы пользователя - вредны для приватности?", + "privacy-matters-section-subheader": "Сохранение конфиденциальности Ваших метаданных — с кем Вы общаетесь — защищает Вас от:", + "if-you-already-installed": "Если Вы уже установили", + "simplex-explained-tab-3-p-2": "Пользователи могут повысить свою конфиденциальность используя сеть Tor для доступа к серверам.", + "join": "Присоединяйтесь к", + "privacy-matters-section-header": "Почему конфиденциальность важна", + "hero-overlay-1-textlink": "Почему идентификаторы пользователя уменьшают конфиденциальность?", "on-this-page": "На этой странице", "privacy-matters-overlay-card-1-p-2": "Интернет-магазины знают, что люди с более низкими доходами с большей вероятностью совершают срочные покупки, поэтому они могут устанавливать более высокие цены или отменять скидки.", - "simplex-unique-3-overlay-1-title": "Контроль и безопасность ваших данных", + "simplex-unique-3-overlay-1-title": "Контроль и безопасность Ваших данных", "protocol-3-text": "Протоколы P2P", - "simplex-private-card-6-point-2": "Чтобы предотвратить это, приложения SimpleX передают одноразовые ключи внеполосно, когда вы делитесь адресом в виде ссылки или QR-кода.", + "simplex-private-card-6-point-2": "Чтобы предотвратить это, приложения SimpleX передают одноразовые ключи вне протокола, когда Вы делитесь адресом в виде ссылки или QR-кода.", "no": "Нет", - "contact-hero-header": "Вы получили адрес для подключения в SimpleX Chat", + "contact-hero-header": "Вы получили адрес контакта для соединения в SimpleX Chat", "feature-8-title": "Режим инкогнито —
уникальный для SimpleX Chat", - "why-simplex": "Что делает SimpleX уникальным", + "why-simplex": "Почему SimpleX", "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", - "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на вашем телефоне или планшете.", + "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на Вашем телефоне или планшете.", "simplex-unique-2-overlay-1-title": "Лучшая защита от спама и злоупотреблений", - "simplex-private-6-title": "Внеполосный
Обмен ключами", - "join-us-on-GitHub": "Присоединяйтесь к нам на GitHub", + "simplex-private-6-title": "Обмен ключами
вне протокола", + "join-us-on-GitHub": "Присоединяйтесь на GitHub", "comparison-section-header": "Сравнение с другими протоколами", - "invitation-hero-header": "Вы получили одноразовую ссылку для подключения в SimpleX Chat", - "no-secure": "Нет - безопасно", - "hero-overlay-card-1-p-2": "Для доставки сообщений вместо идентификаторов пользователей, используемых всеми другими платформами, SimpleX использует временные, анонимные, попарные идентификаторы очередей сообщений, отдельно для каждого из ваших контактов — нет долгосрочных идентификаторов.", + "invitation-hero-header": "Вы получили одноразовую ссылку для соединения в SimpleX Chat", + "no-secure": "Нет - безопасный", + "hero-overlay-card-1-p-2": "Для доставки сообщений вместо идентификаторов пользователей, используемых другими сетями, SimpleX использует временные, анонимные, парные идентификаторы очередей сообщений, отдельные для каждого Вашего контакта — без долгосрочных идентификаторов.", "simplex-explained-tab-1-p-2": "Как это может работать с однонаправленными очередями и без идентификаторов профиля пользователя?", "simplex-network-1-header": "В отличие от P2P-сетей", "jobs": "Присоединиться к команде", "simplex-private-card-7-point-2": "Если какое-либо сообщение будет добавлено, удалено или изменено, получатель будет предупрежден об этом.", - "simplex-unique-3-title": "Только вы контролируете
свои данные", + "simplex-unique-3-title": "Только Вы контролируете
Ваши данные", "guide-dropdown-3": "Секретные группы", "no-resilient": "Нет - устойчив", - "hide-info": "Спрятать информацию", + "hide-info": "Скрыть информацию", "privacy-matters-overlay-card-3-p-4": "Недостаточно просто использовать мессенджер со сквозным шифрованием, мы все должны использовать мессенджеры, которые защищают конфиденциальность наших личных сетей — с какими людьми мы связаны.", - "releases-to-this-repo-are-done-1-2-days-later": "Выпуск новых версий в этом репозитории выходит с задержкой в несколько дней", + "releases-to-this-repo-are-done-1-2-days-later": "Новые версии в этом репозитории публикуются с задержкой в несколько дней", "comparison-point-1-text": "Требуется глобальный идентификатор", "comparison-section-list-point-5": "Не защищает конфиденциальность пользовательских метаданных", - "hero-overlay-card-2-p-2": "Затем они могли бы сопоставить эту информацию с существующими общедоступными социальными сетями и определить некоторые реальные личности.", - "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные графики для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"премия за бедность\".", + "hero-overlay-card-2-p-2": "Эта информация может быть сопоставлена с социальными сетями, чтобы определить реальные личности пользователей.", + "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные сети для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"наценка за бедность\".", "comparison-point-3-text": "Зависимость от DNS", "yes": "Да", "docs-dropdown-6": "Сервера WebRTC", "newer-version-of-eng-msg": "Существует более новая версия этой страницы на английском языке.", "install-simplex-app": "Установите приложение SimpleX", - "hero-overlay-3-textlink": "Оценка безопасности", + "hero-overlay-3-textlink": "Аудит безопасности", "comparison-point-2-text": "Возможность MITM", - "scan-the-qr-code-with-the-simplex-chat-app-description": "Открытые ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Публичные ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", "guide-dropdown-8": "Настройки приложения", - "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной картины общения пользователя или подключений.", + "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной информации о разговорах и контактах пользователя.", "smp-protocol": "Протокол SMP", "open-simplex-app": "Откройте приложение SimpleX", "see-simplex-chat": "Инструкции по загрузке или компиляции SimpleX Chat из исходного кода приведены в", "terminal-cli": "Приложение для терминала (CLI)", "comparison-section-list-point-1": "Обычно требуется номера телефона, в некоторых случаях — имя пользователя", - "simplex-explained-tab-3-text": "3. Что видят сервера", - "github-repository": "репозиторий Github", + "simplex-explained-tab-3-text": "3. Что видят серверы", + "github-repository": "репозитории GitHub", "feature-5-title": "Исчезающие сообщения", "connect-in-app": "Подключитесь в приложении", "menu": "Меню", - "simplex-private-card-4-point-1": "Чтобы защитить свой IP-адрес вы можете подключаться к серверам через сеть Tor или какую-либо другую транспортную оверлейную сеть.", - "privacy-matters-3-title": "Судебное преследование не виновных", - "comparison-point-5-text": "Атака на центральный компонент или другая сетевая атака", + "simplex-private-card-4-point-1": "Чтобы защитить свой IP-адрес Вы можете подключаться к серверам через сеть Tor или какую-либо другую транспортную оверлейную сеть.", + "privacy-matters-3-title": "Преследование из-за невиновного общения", + "comparison-point-5-text": "Центральный компонент или атака на всю сеть", "click-to-see": "Нажать здесь, чтобы увидеть", - "donate-here-to-help-us": "Пожертвуйте здесь, чтобы помочь нам", + "donate-here-to-help-us": "Пожертвовать", "simplex-private-1-title": "2-уровневое
сквозное шифрование", - "simplex-unique-card-1-p-2": "В отличие от любой другой существующей платформы обмена сообщениями, SimpleX не имеет идентификаторов пользователей — нету даже случайных цифр.", - "privacy-matters-2-overlay-1-title": "Конфиденциальность дает вам власть", - "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", + "simplex-unique-card-1-p-2": "В отличие от любой другой существующей сети обмена сообщениями, SimpleX не имеет идентификаторов пользователей — нет даже случайных цифр.", + "privacy-matters-2-overlay-1-title": "Конфиденциальность дает Вам власть", + "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, Вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", "simplex-unique-4-overlay-1-title": "Полностью децентрализованная — пользователи владеют сетью SimpleX", "guide-dropdown-2": "Отправка сообщений", - "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по его смягчению требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не имеет функционала по обмену серверами, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку по всей сети - невозможно.", - "simplex-private-2-title": "Дополнительный уровень
шифрования сервера", - "hero-overlay-card-1-p-4": "Такая конструкция предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты вашего IP-адреса вы можете подключиться к серверам обмена сообщениями через сеть Tor.", + "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по уменьшению риска требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не позволяет обнаруживать серверы, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку на всю сеть невозможно.", + "simplex-private-2-title": "Дополнительный уровень
шифрования с сервером", + "hero-overlay-card-1-p-4": "Этот подход предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты Вашего IP-адреса Вы можете подключиться к серверам обмена сообщениями через сеть Tor.", "f-droid-org-repo": "Репозиторий F-Droid.org", "guide-dropdown-4": "Профили чата", "simplex-network-2-header": "В отличие от федеративных сетей", - "see-here": "подробней тут", - "simplex-private-3-title": "Безопасный аутентифицированный
протокол TLS", - "comparison-section-list-point-3": "Открытый ключ или какой-либо другой глобально уникальный идентификатор", - "hero-overlay-card-2-p-3": "Даже в самых приватных приложениях, использующих скрытые сервисы Tor v3, если вы общаетесь с двумя разными контактами через один и тот же профиль, они могут доказать, что они являются связаны с одним и тем же человеком.", - "simplex-private-4-title": "Вариант доступа
через сеть Tor", + "see-here": "подробнее здесь", + "simplex-private-3-title": "Безопасное аутентифицированное
соединение TLS", + "comparison-section-list-point-3": "Публичный ключ или какой-либо другой глобально уникальный идентификатор", + "hero-overlay-card-2-p-3": "Даже в самых конфиденциальных приложениях, использующих скрытые адреса Tor, если Вы общаетесь с двумя разными контактами через один и тот же профиль, они могут доказать, что они связаны с одним и тем же человеком.", + "simplex-private-4-title": "Возможен доступ
через сеть Tor", "privacy-matters-1-title": "Реклама и ценовая дискриминация", "simplex-unique-card-3-p-1": "SimpleX хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных — их можно перенести на другое устройство.", - "hero-overlay-1-title": "Как работает SimpleX?", + "hero-overlay-1-title": "Как SimpleX работает?", "stable-versions-built-by-f-droid-org": "Стабильные версии, созданные F-Droid.org", - "contact-hero-p-3": "Воспользуйтесь ссылками ниже, чтобы загрузить приложение.", + "contact-hero-p-3": "Используйте ссылки ниже, чтобы загрузить приложение.", "simplex-network": "Сеть SimpleX", - "privacy-matters-3-overlay-1-title": "Конфиденциальность защищает вашу свободу", + "privacy-matters-3-overlay-1-title": "Конфиденциальность защищает Вашу свободу", "docs-dropdown-7": "Перевести SimpleX Chat", "back-to-top": "Вернуться к началу", "simplex-network-1-desc": "Все сообщения отправляются через серверы, что обеспечивает лучшую конфиденциальность метаданных и надежную асинхронную доставку сообщений, избегая при этом многих", "simplex-chat-repo": "Репозиторий SimpleX Chat", - "simplex-private-card-6-point-1": "Многие коммуникационные платформы уязвимы для MITM-атак со стороны серверов или сетевых провайдеров.", - "privacy-matters-3-overlay-1-linkText": "Конфиденциальность защищает вашу свободу", - "simplex-unique-overlay-card-1-p-2": "Для доставки сообщений SimpleX использует попарные, анонимные адреса однонаправленных очередей сообщений, раздельные для полученных и отправленных сообщений, обычно через разные серверы. Использование SimpleX это как иметь отдельный “одноразовый” адрес электронной почты или номер телефона для каждого контакта, при это, не обременяя вас управлять эти вручную.", - "simplex-unique-overlay-card-3-p-4": "Со стороны не видно разницу между отправлением или получением сообщений — если кто-то наблюдает за этим, он не cможет легко определить, кто с кем общается, даже если протокол TLS будет скомпрометирован.", + "simplex-private-card-6-point-1": "Многие коммуникационные сети уязвимы для MITM-атак со стороны серверов или сетевых провайдеров.", + "privacy-matters-3-overlay-1-linkText": "Конфиденциальность защищает Вашу свободу", + "simplex-unique-overlay-card-1-p-2": "Для доставки сообщений SimpleX использует попарные, анонимные адреса однонаправленных очередей сообщений, раздельные для полученных и отправленных сообщений, обычно через разные серверы.", + "simplex-unique-overlay-card-3-p-4": "Со стороны не видно разницы между отправлением или получением сообщений — если кто-то наблюдает за этим, он не cможет легко определить, кто с кем общается, даже если протокол TLS будет скомпрометирован.", "docs-dropdown-2": "Доступ к файлам в версии для Android", - "get-simplex": "Скачать SimpleX для ПК", - "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть вас опасности, например за ваши политические взгляды, даже если кажется, что вам \"нечего скрывать\".", + "get-simplex": "Скачать SimpleX для компьютера", + "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть Вас опасности, например за Ваши политические взгляды, даже если кажется, что Вам \"нечего скрывать\".", "simplex-unique-2-title": "Вы защищены от
спама и злоупотреблений", - "simplex-unique-overlay-card-4-p-3": "Если вы рассматриваете возможность разработки платформе SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", + "simplex-unique-overlay-card-4-p-3": "Если Вы рассматриваете возможность разработки для сети SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в Ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", "comparison-section-list-point-2": "Адреса на основе DNS", "stable-and-beta-versions-built-by-developers": "Стабильные и бета-версии, созданные разработчиками", "simplex-network-3-header": "Сеть SimpleX", - "simplex-unique-card-2-p-1": "Поскольку у вас нет идентификатора или фиксированного адреса на платформе SimpleX, никто не сможет связаться с вами, без вашего явного согласия, только если вы сами поделитесь адресом в виде QR-кода или ссылки.", - "hero-overlay-card-3-p-1": "Trail of Bits - ведущая консалтинговая компания в области безопасности и технологий, клиентами которой являются крупные технологические компании, правительственные агентства и крупные блокчейн проекты.", - "hero-header": "Иной взгляд на приватность", + "simplex-unique-card-2-p-1": "Поскольку у Вас нет идентификатора или фиксированного адреса в сети SimpleX, никто не сможет связаться с Вами без Вашего явного согласия mdash; это возможно, только если Вы сами поделитесь адресом в виде QR-кода или ссылки.", + "hero-overlay-card-3-p-1": "Trail of Bits — ведущая консалтинговая компания в области безопасности, клиентами которой являются крупные технологические компании, правительственные агентства и крупные блокчейн проекты.", + "hero-header": "Более конфиденциальный", "comparison-section-list-point-4": "Если операторы серверов скомпрометированы. В Signal, и некоторых других приложениях, есть возможность подтвердить код безопасности", - "simplex-private-card-2-point-1": "Дополнительный уровень серверного шифрования для доставки получателю, чтобы предотвратить корреляцию между полученным и отправленным трафиком сервера, если используемый протокол TLS скомпрометированный.", - "f-droid-page-simplex-chat-repo-section-text": "Чтобы добавить его в свой клиент F-Droid, отсканируйте QR-код или воспользуйтесь этим URL-адресом:", - "guide-dropdown-7": "Приватность и безопасность", - "join-the-REDDIT-community": "Присоединяйтесь к нам в сообществе REDDIT", - "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, обеспечивая лучшую конфиденциальность метаданных, чем другие альтернативы.", + "simplex-private-card-2-point-1": "Дополнительный уровень шифрования между сервером и получателем для предотвращения корреляции полученного и отправленного трафика сервера, в случае компрометированного TLS.", + "f-droid-page-simplex-chat-repo-section-text": "Чтобы добавить его в свой клиент F-Droid, отсканируйте QR-код или используйте этот адрес:", + "guide-dropdown-7": "Конфиденциальность и безопасность", + "join-the-REDDIT-community": "Присоединяйтесь в REDDIT", + "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, и обеспечивает лучшую конфиденциальность метаданных, чем альтернативы.", "privacy-matters-2-title": "Манипулирование выборами", - "home": "Домашняя страница", + "home": "Главная", "chat-protocol": "Протокол чата", - "simplex-private-card-5-point-2": "Это позволяет сообщениям разного размера выглядеть одинаково для серверов и сетевых наблюдателей.", - "hero-overlay-card-1-p-1": "Многие спрашивают:Если у SimpleX нету никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", - "feature-6-title": "Зашифрованные E2E-шифрованием аудио и видео звонки", - "hero-p-1": "Другие приложения имеют ID своих пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не имет, нету даже случайных цифр.
Это значительно повышает вашу приватность.", - "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторов его пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователя.", - "simplex-unique-4-title": "Только вы владеете
сетью SimpleX", + "simplex-private-card-5-point-2": "Это делает сообщениям разного размера одинаковыми для серверов и сети.", + "hero-overlay-card-1-p-1": "Многие спрашивают: Если у SimpleX нет никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", + "feature-6-title": "Аудио и видео звонки с E2E-шифрованием", + "hero-p-1": "Другие приложения используют ID пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не использует, даже случайных цифр.
Это значительно повышает Вашу конфиденциальность.", + "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторах пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователей.", + "simplex-unique-4-title": "Вы владеете
сетью SimpleX", "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", - "blog": "Новости", - "simplex-private-card-7-point-1": "Чтобы гарантировать целостность, сообщения последовательно нумеруются и включают в себя хэш предыдущего сообщения.", - "simplex-unique-overlay-card-4-p-2": "Платформа SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем возможности увидеть, какие сервисы SimpleX вы сможете создать.", + "blog": "Блог", + "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хэш предыдущего сообщения.", + "simplex-unique-overlay-card-4-p-2": "Сеть SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем сервисы SimpleX, которые Вы создадите.", "simplex-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", "contact-hero-p-2": "Еще не скачали SimpleX Chat?", - "why-simplex-is": "Почему SimpleX", + "why-simplex-is-unique": "Почему SimpleX уникальный", "simplex-network-section-header": "Сеть SimpleX", "simplex-private-10-title": "Временные анонимные парные идентификаторы", - "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит ваши деньги", + "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит Ваши деньги", "tap-the-connect-button-in-the-app": "Нажмите на кнопку ’подключиться’ в приложении", "comparison-section-list-point-4a": "Сервера SimpleX не могут скомпрометировать сквозное шифрование", - "unique": "уникальный", "simplex-network-1-overlay-linktext": "проблем P2P сетей", - "no-private": "Нет - приватно", - "simplex-unique-1-title": "У вас есть полная
конфиденциальность", + "no-private": "Нет - конфиденциальный", + "simplex-unique-1-title": "У Вас есть полная
конфиденциальность", "protocol-2-text": "XMPP, Matrix", "guide": "Руководство", - "simplex-network-overlay-card-1-li-4": "Реализации P2P могут быть заблокированы некоторыми интернет-провайдерами (например, BitTorrent). SimpleX не зависит от транспорта - он может работать по стандартным веб-протоколам, например WebSockets.", - "hero-overlay-2-title": "Почему идентификаторы пользователя - вредны для приватности?", + "simplex-network-overlay-card-1-li-4": "Реализации P2P могут быть заблокированы некоторыми интернет-провайдерами (например, BitTorrent). SimpleX протокол не зависит от транспорта — он может быть реализован через стандартные веб-протоколы, например WebSockets.", + "hero-overlay-2-title": "Почему идентификаторы пользователя уменьшают конфиденциальность?", "simplex-explained-tab-2-text": "2. Как это работает", "docs-dropdown-4": "Свой SMP Сервер", - "feature-4-title": "Зашифрованные E2E-шифрованием голосовые сообщения", - "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши социальные графики для искажения нашего мнения из реального мира и манипулируют нашими голосами.", - "privacy-matters-overlay-card-2-p-3": "SimpleX - это первая платформа, которая по своей конструкции не имеет никаких идентификаторов пользователей, таким образом защищая график ваших контактов лучше, чем любая известная альтернатива.", - "learn-more": "Учить больше", + "feature-4-title": "Голосовые сообщения с E2E-шифрованием", + "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши контакты в социальных сетях, чтобы изменять наши мнения и манипулировать нашими голосами.", + "privacy-matters-overlay-card-2-p-3": "SimpleX — это первая сеть, которая не имеет никаких идентификаторов пользователей, таким образом защищая Ваши контакты лучше, чем любая известная альтернатива.", + "learn-more": "Узнать больше", "donate": "Пожертвовать", "simplex-private-8-title": "Смешивание сообщений
для уменьшения корреляции", "scan-qr-code-from-mobile-app": "Отсканируйте QR-код в мобильном приложении", "simplex-private-card-3-point-3": "Возобновление соединения отключено для предотвращения сеансовых атак.", - "simplex-private-card-10-point-1": "SimpleX использует временные анонимные попарные адреса и учетные данные для каждого контакта пользователя или члена группы.", + "simplex-private-card-10-point-1": "SimpleX использует временные анонимные парные адреса и учетные данные для каждого контакта пользователя или члена группы.", "guide-dropdown-6": "Аудио и видео Звонки", - "more-info": "Больше информации", + "more-info": "Дополнительная информации", "no-decentralized": "Нет - децентрализованный", "protocol-1-text": "Signal, большие платформы", - "hero-2-header-desc": "В видео показано, как подключиться к своему другу с помощью его одноразового QR-кода, лично или по видеосвязи. Вы также можете подключиться, поделившись ссылкой-приглашением.", - "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и усиливать/увеличивать объём трафика, что приводит к отказу в обслуживании по всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для нагрузки трафика всей сети.", - "if-you-already-installed-simplex-chat-for-the-terminal": "Если вы уже установили SimpleX Chat для терминала", + "hero-2-header-desc": "В видео показано, как подключиться к Вашему другу через одноразовый QR-код, при встрече или во время видеосвязи. Вы также можете соединится, поделившись ссылкой-приглашением.", + "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и увеличивать трафик, что приводит к отказу всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для создания трафика во всей сети.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Если Вы уже установили SimpleX Chat для терминала", "docs-dropdown-8": "Служба Каталогов SimpleX", - "simplex-private-card-1-point-1": "Протокол с двойным храповым механизмом —
обмен сообщениями OTR с идеальной секретностью пересылки и восстановлением после взлома.", + "simplex-private-card-1-point-1": "Протокол двойного обновления ключей —
\"отрицаемые\" сообщения с идеальной прямой секретностью и восстановлением после взлома.", "simplex-private-card-8-point-1": "Серверы SimpleX действуют как узлы-миксеры с низкой задержкой — входящие и исходящие сообщения имеют разный порядок.", - "simplex-unique-overlay-card-2-p-1": "Поскольку у вас нет идентификатора на платформе SimpleX, никто не сможет связаться с вами, если вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", - "sign-up-to-receive-our-updates": "Введите ваш Email, чтобы получать рассылку обновлений от нас", + "simplex-unique-overlay-card-2-p-1": "Поскольку у Вас нет идентификатора в сети SimpleX, никто не сможет связаться с Вами, если Вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", + "sign-up-to-receive-our-updates": "Подпишитесь на нашу рассылку новостей", "guide-dropdown-1": "Быстрый старт", - "simplex-explained-tab-2-p-1": "Для каждого подключения вы используете две отдельные очереди обмена сообщениями, то есть отправка и получения сообщений происходит через разные серверы.", - "simplex-private-section-header": "Что делает SimpleX приватным", - "we-invite-you-to-join-the-conversation": "Мы приглашаем вас присоединиться к беседе", - "feature-2-title": "Изображения, видео и файлы
зашифрованные E2E-шифрованием", + "simplex-explained-tab-2-p-1": "Для каждого контакта Вы используете две отдельные очереди сообщений, отправляя и получая сообщения через разные серверы.", + "simplex-private-section-header": "Что делает SimpleX конфиденциальным", + "we-invite-you-to-join-the-conversation": "Мы приглашаем Вас присоединиться к разговору", + "feature-2-title": "Изображения, видео и файлы
с E2E-шифрованием", "simplex-private-9-title": "Однонаправленные
очереди сообщений", - "simplex-unique-overlay-card-1-p-3": "Этот дизайн защищает конфиденциальность того, с кем вы общаетесь, скрывая это от серверов SimpleX и от любых наблюдателей из вне. Чтобы скрыть свой IP-адрес от серверов, вы можете подключиться к серверам SimpleX через сеть Tor.", + "simplex-unique-overlay-card-1-p-3": "Этот дизайн защищает конфиденциальность Ваших контактов, скрывая их от серверов SimpleX и от любых внешних наблюдателей. Чтобы скрыть свой IP-адрес от серверов, Вы можете подключиться к серверам SimpleX через сеть Tor.", "developers": "Разработчики", - "simplex-private-7-title": "Проверка целостности
сообщения", - "privacy-matters-overlay-card-1-p-4": "Платформа SimpleX защищает конфиденциальность ваших контактов лучше, чем любая другая альтернатива, полностью предотвращая доступ к вашему социальному графику каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", - "hero-overlay-card-1-p-6": "Подробнее читайте в техническом документе SimpleX.", + "simplex-private-7-title": "Проверка неизменности
сообщений", + "privacy-matters-overlay-card-1-p-4": "Сеть SimpleX защищает конфиденциальность Ваших контактов лучше, чем альтернативы, предотвращая доступ к Вашей социальной сети каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", + "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", "terms-and-privacy-policy": "Политика Конфиденциальности", - "simplex-network-overlay-card-1-li-1": "Сети P2P полагаются на тот или иной вариант DHT для маршрутизации сообщений. Проекты DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети - O(log N).", - "privacy-matters-section-label": "Убедитесь, что ваш мессенджер не может получить доступ к вашим данным!", + "simplex-network-overlay-card-1-li-1": "Сети P2P используют DHT (распределенные хэш-таблицы) для маршрутизации сообщений. DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети — O(log N).", + "privacy-matters-section-label": "Убедитесь, что Ваш мессенджер не может получить доступ к Вашим данным!", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных которую можно перенести на другое устройство.", "simplex-network-3-desc": "серверы предоставляют однонаправленные очереди для подключения пользователей, но у них нет видимости графика сетевых подключений — это делают только пользователи.", - "simplex-unique-card-1-p-1": "SimpleX защищает конфиденциальность вашего профиля, контактов и метаданных, скрывая их от серверов платформы SimpleX и любых наблюдателей.", - "simplex-private-card-3-point-1": "Для соединений клиент-сервер используется только протокол TLS 1.2/1.3 с надежными алгоритмами.", - "simplex-unique-card-4-p-1": "Сеть SimpleX полностью децентрализована и независима от любой криптовалюты/блокчейна или любой другой платформы, кроме Интернета.", - "features": "Возможности", - "hero-overlay-card-1-p-3": "Вы определяете, какие серверы будете использовать для получения сообщений, а ваши контакты — серверы, которые вы используете для отправки им сообщений. Каждый новый чат, скорее всего, будет вестись на двух разных серверах.", + "simplex-unique-card-1-p-1": "SimpleX защищает конфиденциальность Вашего профиля, контактов и метаданных, скрывая их от серверов сети SimpleX и любых наблюдателей.", + "simplex-private-card-3-point-1": "Для соединений клиента и сервера используется только протокол TLS 1.2/1.3 с безопасными алгоритмами.", + "simplex-unique-card-4-p-1": "Сеть SimpleX полностью децентрализована и независима от любой криптовалюты/блокчейна или любой другой сети, кроме Интернета.", + "features": "Функции", + "hero-overlay-card-1-p-3": "Вы определяете, какие серверы использовать для получения сообщений, а Ваши контакты — серверы, которые Вы используете для отправки им сообщений. Каждый новый чат, скорее всего, будет вестись на двух разных серверах.", "docs-dropdown-9": "Скачать", "simplex-chat-protocol": "Протокол SimpleX Chat", - "simplex-unique-overlay-card-1-p-1": "В отличие от других платформ обмена сообщениями, SimpleX не имеет идентификаторов, присвоенных пользователям. Он не полагается на номера телефонов, доменные адреса (например, электронную почту или XMPP), имена пользователей, открытые ключи или даже случайные числа для идентификации своих пользователей — мы не знаем, сколько людей пользуются нашими SimpleX серверами.", - "reference": "Ссылки", - "f-droid-page-f-droid-org-repo-section-text": "Приложение SimpleX Chat от разработчиков и от репозитория F-Droid.org имеют разные ключи подписи. Если вы хотите сменить одно на другое, вам сначала нужно будет экспортировать базу данных и только потом скачать другое приложение.", - "simplex-private-5-title": "Многоуровневое
Заполнения содержимого", - "please-use-link-in-mobile-app": "Пожалуйста, воспользуйтесь ссылкой в мобильном приложении", + "simplex-unique-overlay-card-1-p-1": "В отличие от других сетей обмена сообщениями, SimpleX не имеет идентификаторов, присвоенных пользователям. Он не полагается на номера телефонов, доменные адреса (например, электронную почту или XMPP), имена пользователей, публичные ключи или даже случайные числа для идентификации своих пользователей — операторы серверов SimpleX не знают, сколько людей пользуются их серверами.", + "reference": "Документы", + "f-droid-page-f-droid-org-repo-section-text": "Приложение SimpleX Chat от разработчиков и от репозитория F-Droid.org имеют разные ключи подписи. Если Вы хотите сменить одно на другое, Вам сначала нужно будет экспортировать базу данных и только потом скачать другое приложение.", + "simplex-private-5-title": "Многоуровневое
дополнение сообщений", + "please-use-link-in-mobile-app": "Пожалуйста, используйте ссылку в мобильном приложении", "please-enable-javascript": "Пожалуйста, включите JavaScript, чтобы увидеть QR-код.", - "docs-dropdown-10": "Прозрачность", + "docs-dropdown-10": "Запросы данных", "docs-dropdown-12": "Безопасность", - "docs-dropdown-11": "Часто задаваемые вопросы" + "docs-dropdown-11": "Часто задаваемые вопросы", + "docs-dropdown-14": "SimpleX для бизнеса" } diff --git a/website/langs/uk.json b/website/langs/uk.json index 794c65c956..1c1780edb3 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -14,19 +14,19 @@ "simplex-network-overlay-card-1-li-6": "P2P-мережі можуть бути вразливими до атаки DRDoS, коли клієнти можуть ребродкастити та збільшувати трафік, що призводить до відмови в обслуговуванні на рівні всієї мережі. Клієнти SimpleX лише пересилають трафік від відомого підключення і не можуть бути використані зловмисником для збільшення трафіку в усій мережі.", "privacy-matters-overlay-card-1-p-2": "Інтернет-роздрібники знають, що люди з низьким доходом частіше роблять термінові покупки, тому вони можуть встановлювати вищі ціни або скасовувати знижки.", "privacy-matters-overlay-card-1-p-3": "Деякі фінансові та страхові компанії використовують соціальні графи для визначення ставок та страхових премій. Це часто змушує людей з низькими доходами платити більше — це відомо як 'поширений преміум'.", - "privacy-matters-overlay-card-1-p-4": "Платформа SimpleX захищає конфіденційність ваших з'єднань краще, ніж будь-яка альтернатива, повністю запобігаючи доступу вашого соціального графа будь-яким компаніям чи організаціям. Навіть коли люди використовують сервери, надані SimpleX Chat, ми не знаємо кількість користувачів чи їх зв'язки.", + "privacy-matters-overlay-card-1-p-4": "Мережа SimpleX краще захищає конфіденційність ваших з'єднань, ніж будь-яка інша альтернатива, повністю запобігаючи тому, щоб ваш соціальний граф став доступним для будь-яких компаній чи організацій. Навіть коли люди використовують сервери, попередньо налаштовані в додатках SimpleX Chat, оператори серверів не знають кількості користувачів або їхніх з'єднань.", "privacy-matters-overlay-card-2-p-1": "Не так давно ми спостерігали, як великі вибори маніпулювалися поважною консалтинговою компанією, яка використовувала наші соціальні графи для спотворення нашого уявлення про реальний світ та маніпулювання нашими голосами.", - "privacy-matters-overlay-card-2-p-2": "Щоб бути об'єктивним та приймати незалежні рішення, вам потрібно контролювати ваш інформаційний простір. Це можливо лише за умови використання приватної платформи для спілкування, яка не має доступу до вашого соціального графа.", - "privacy-matters-overlay-card-2-p-3": "SimpleX - це перша платформа, яка не має жодних ідентифікаторів користувачів за своїм дизайном, таким чином, захищаючи ваш графік з'єднань краще, ніж будь-яка відома альтернатива.", + "privacy-matters-overlay-card-2-p-2": "Щоб бути об'єктивним та приймати незалежні рішення, вам потрібно контролювати ваш інформаційний простір. Це можливо лише за умови використання приватної мережі для спілкування, яка не має доступу до вашого соціального графа.", + "privacy-matters-overlay-card-2-p-3": "SimpleX - це перша мережа, яка не має жодних ідентифікаторів користувачів за своїм дизайном, таким чином, захищаючи ваш графік з'єднань краще, ніж будь-яка відома альтернатива.", "privacy-matters-overlay-card-3-p-1": "Кожен повинен турбуватися про конфіденційність та безпеку свого спілкування — безпечні розмови можуть поставити вас під загрозу, навіть якщо у вас немає чого приховувати.", "privacy-matters-overlay-card-3-p-2": "Однією з найшокуючих історій є досвід Мохамеду Ульд Слахі, описаний в його мемуарах і показаний у фільмі \"Мавританець\". Його посадили в табір Гуантанамо без суду і мукили там 15 років після телефонного дзвінка йому родичеві в Афганістані, під підозрою в причетності до атак 11 вересня, навіть не дивлячись на те, що він проживав у Німеччині протягом попередніх 10 років.", "privacy-matters-overlay-card-3-p-4": "Недостатньо використовувати зашифрований від кінця до кінця месенджер; ми всі повинні використовувати месенджери, які захищають конфіденційність наших особистих мереж — з ким ми з'єднані.", - "simplex-unique-overlay-card-1-p-1": "На відміну від інших платформ обміну повідомленнями, у SimpleX не присвоюються ідентифікатори користувачів. Він не покладається на номери телефонів, адреси на основі домену (наприклад, електронна пошта або XMPP), імена користувачів, публічні ключі або навіть випадкові номери для ідентифікації своїх користувачів — ми не знаємо, скільки людей використовує наші сервери SimpleX.", + "simplex-unique-overlay-card-1-p-1": "На відміну від інших мереж для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам. Він не покладається на номери телефонів, адреси на основі доменів (як електронна пошта або XMPP), імена користувачів, публічні ключі або навіть випадкові числа для ідентифікації своїх користувачів — оператори серверів SimpleX не знають, скільки людей використовують їхні сервери.", "simplex-unique-overlay-card-3-p-3": "На відміну від федеративних мереж серверів (електронна пошта, XMPP або Matrix), сервери SimpleX не зберігають облікові записи користувачів, вони лише ретранслюють повідомлення, захищаючи приватність обох сторін.", "simplex-unique-card-3-p-2": "Зашифровані повідомлення від кінця до кінця тимчасово зберігаються на ретрансляційних серверах SimpleX до їх отримання, після чого вони назавжди видаляються.", - "simplex-unique-card-4-p-1": "Мережа SimpleX є повністю децентралізованою та незалежною від будь-якої криптовалюти чи іншої платформи, крім Інтернету.", + "simplex-unique-card-4-p-1": "Мережа SimpleX повністю децентралізована та незалежна від будь-якої криптовалюти або будь-якої іншої мережі, окрім Інтернету.", "simplex-network-overlay-card-1-li-3": "P2P не вирішує проблему атаки MITM, і більшість існуючих реалізацій не використовують поза каналом повідомлень для початкового обміну ключами. SimpleX використовує поза каналом повідомлень або, у деяких випадках, передбачені і безпечні з'єднання для початкового обміну ключами.", - "simplex-network-overlay-card-1-li-4": "Реалізації P2P можуть бути блоковані деякими інтернет-провайдерами (наприклад, BitTorrent). SimpleX є транспортно-агностичним - він може працювати через стандартні веб-протоколи, наприклад, WebSockets.", + "simplex-network-overlay-card-1-li-4": "Реалізації P2P можуть бути заблоковані деякими інтернет-провайдерами (наприклад, BitTorrent). SimpleX є незалежним від транспорту — він може працювати через стандартні веб-протоколи, наприклад, WebSockets.", "simplex-unique-card-4-p-2": "Ви можете використовувати SimpleX з власними серверами або з серверами, які ми надаємо — і все одно підключатися до будь-якого користувача.", "contact-hero-p-1": "Публічні ключі та адреса черги повідомлень в цьому посиланні НЕ відправляються по мережі під час перегляду цієї сторінки — вони містяться в хеш-фрагменті URL-посилання.", "scan-qr-code-from-mobile-app": "Сканувати QR-код з мобільного додатка", @@ -46,7 +46,7 @@ "guide-dropdown-8": "Налаштування додатка", "guide-dropdown-9": "Створення підключень", "guide": "Посібник", - "docs-dropdown-1": "Платформа SimpleX", + "docs-dropdown-1": "SimpleX мережа", "docs-dropdown-2": "Доступ до файлів Android", "docs-dropdown-3": "Доступ до бази даних чату", "docs-dropdown-4": "Хостинг сервера SMP", @@ -71,14 +71,14 @@ "simplex-explained-tab-1-p-1": "Ви можете створювати контакти та групи і вести двосторонні розмови, як у будь-якому іншому месенджері.", "simplex-explained-tab-1-p-2": "Як це може працювати з однобічними чергами та без ідентифікаторів профілю користувача?", "simplex-explained-tab-2-p-1": "Для кожного з'єднання ви використовуєте дві окремі черги обміну повідомленнями для відправки та отримання повідомлень через різні сервери.", - "simplex-explained-tab-2-p-2": "Сервери передають повідомлення тільки в одному напрямку, не маючи повної картини розмови або підключень користувача.", + "simplex-explained-tab-2-p-2": "Сервери передають повідомлення тільки в одному напрямку, не маючи повної картини розмов або підключень користувача.", "simplex-explained-tab-3-p-1": "Сервери мають окремі анонімні облікові дані для кожної черги і не знають, які користувачі до них відносяться.", "simplex-explained-tab-3-p-2": "Користувачі можуть додатково підвищити конфіденційність метаданих, використовуючи Tor для доступу до серверів, що запобігає кореляції за IP-адресою.", "chat-bot-example": "Приклад чат-бота", "smp-protocol": "Протокол SMP", "chat-protocol": "Протокол чату", "donate": "Пожертвувати", - "copyright-label": "© 2020-2024 SimpleX | Проект з відкритим кодом", + "copyright-label": "© 2020-2025 SimpleX | Проект з відкритим кодом", "simplex-chat-protocol": "Протокол чату SimpleX", "terminal-cli": "Термінал CLI", "hero-header": "Приватність переосмислена", @@ -116,7 +116,7 @@ "simplex-private-card-4-point-2": "Для використання SimpleX через Tor, будь ласка, встановіть додаток Orbot та активуйте SOCKS5-проксі (або VPN на iOS).", "simplex-private-card-5-point-1": "SimpleX використовує наповнення вмісту для кожного шару шифрування для ускладнення атак за розміром повідомлень.", "simplex-private-card-5-point-2": "Це забезпечує, що повідомлення різних розмірів виглядають однаково для серверів та спостерігачів мережі.", - "simplex-private-card-6-point-1": "Багато комунікаційних платформ вразливі до MITM-атак серверів чи постачальників мережі.", + "simplex-private-card-6-point-1": "Багато комунікаційних мереж вразливі до MITM-атак серверів чи постачальників мережі.", "simplex-private-card-6-point-2": "Щоб запобігти цьому, програми SimpleX передають одноразові ключі позаканально, коли ви ділитесь адресою як посиланням або QR-кодом.", "simplex-private-card-7-point-2": "Якщо будь-яке повідомлення додається, вилучається чи змінюється, отримувач буде проінформований.", "simplex-private-card-8-point-1": "Сервери SimpleX виступають як вузли низької затримки для змішування — вхідні та вихідні повідомлення мають різний порядок.", @@ -140,7 +140,7 @@ "simplex-unique-4-title": "Ви власник мережі SimpleX", "simplex-unique-4-overlay-1-title": "Повністю децентралізована — користувачі володіють мережею SimpleX", "hero-overlay-card-1-p-1": "Багато користувачів запитували: якщо у SimpleX немає ідентифікаторів користувачів, як він може знати, куди відправити повідомлення?", - "hero-overlay-card-1-p-2": "Для доставки повідомлень, замість ідентифікаторів користувачів, які використовують усі інші платформи, SimpleX використовує тимчасові анонімні парні ідентифікатори черг повідомлень, окремі для кожного вашого підключення — тут немає довгострокових ідентифікаторів.", + "hero-overlay-card-1-p-2": "Для доставки повідомлень, замість ідентифікаторів користувачів, які використовують усі інші мережі, SimpleX використовує тимчасові анонімні парні ідентифікатори черг повідомлень, окремі для кожного вашого підключення — тут немає довгострокових ідентифікаторів.", "hero-overlay-card-1-p-3": "Ви визначаєте, який(кі) сервер(и) використовувати для отримання повідомлень, ваші контакти — сервери, які ви використовуєте для відправки повідомлень їм. Кожна розмова, ймовірно, використовує два різних сервери.", "hero-overlay-card-1-p-4": "Цей дизайн запобігає витоку метаданих будь-яких користувачів на рівні додатка. Для подальшого покращення конфіденційності та захисту вашої IP-адреси ви можете підключитися до серверів обміну повідомленнями через Tor.", "hero-overlay-card-1-p-6": "Докладніше читайте у білетному запису SimpleX.", @@ -152,19 +152,19 @@ "simplex-network-overlay-card-1-li-2": "У дизайні SimpleX, на відміну від більшості P2P-мереж, немає жодних глобальних ідентифікаторів користувачів будь-якого виду, навіть тимчасових, та використовуються лише тимчасові парні ідентифікатори, що забезпечує кращу анонімність та захист метаданих.", "privacy-matters-overlay-card-1-p-1": "Багато великих компаній використовують інформацію про те, з ким ви з'єднані, щоб оцінити ваш дохід, продавати вам продукти, які вам дійсно не потрібні, і визначати ціни.", "privacy-matters-overlay-card-3-p-3": "Звичайних людей арештовують за те, що вони публікують онлайн, навіть через свої 'анонімні' облікові записи, навіть у демократичних країнах.", - "simplex-unique-overlay-card-1-p-2": "Для доставки повідомлень SimpleX використовує парні анонімні адреси однобічних черг повідомлень, окремо для отриманих та відправлених повідомлень, зазвичай через різні сервери. Використання SimpleX схоже на наявність різної “витратної” електронної пошти або телефону для кожного контакту, і немає неприємностей у їх управлінні.", - "simplex-unique-overlay-card-1-p-3": "Цей дизайн захищає конфіденційність осіб, з якими ви спілкуєтеся, приховуючи це від серверів платформи SimpleX та будь-яких спостерігачів. Щоб сховати свою IP-адресу від серверів, ви можете підключитися до серверів SimpleX через Tor.", - "simplex-unique-overlay-card-2-p-1": "Оскільки у вас немає ідентифікатора на платформі SimpleX, ніхто не може з вами зв'язатися, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, у вигляді QR-коду або посилання.", + "simplex-unique-overlay-card-1-p-2": "Для доставки повідомлень SimpleX використовує парні анонімні адреси однобічних черг повідомлень, окремо для отриманих та відправлених повідомлень, зазвичай через різні сервери.", + "simplex-unique-overlay-card-1-p-3": "Цей дизайн захищає конфіденційність того, з ким ви спілкуєтесь, приховуючи це від серверів мережі SimpleX та від будь-яких спостерігачів. Щоб приховати вашу IP-адресу від серверів, ви можете підключатися до серверів SimpleX через Tor.", + "simplex-unique-overlay-card-2-p-1": "Оскільки у вас немає ідентифікатора в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", "simplex-unique-overlay-card-2-p-2": "Навіть з необов'язковою адресою користувача, яка може бути використана для відправки спамових запитань на зв'язок, ви можете змінити або повністю видалити її, не втрачаючи жодного з ваших з'єднань.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat зберігає всі дані користувачів лише на пристроях клієнтів за допомогою переносного зашифрованого формату бази даних, який можна експортувати і передавати на будь-який підтримуваний пристрій.", "simplex-unique-overlay-card-3-p-2": "Зашифровані повідомлення від кінця до кінця тимчасово зберігаються на ретрансляційних серверах SimpleX до їх отримання, після чого вони назавжди видаляються.", "simplex-unique-overlay-card-3-p-4": "Між надісланим і отриманим серверним трафіком немає спільних ідентифікаторів чи шифрованого тексту — якщо хтось його спостерігає, він не може легко визначити, хто спілкується з ким, навіть якщо TLS скомпрометовано.", - "simplex-unique-overlay-card-4-p-1": "Ви можете використовувати SimpleX з власними серверами і все одно спілкуватися з людьми, які використовують надані нам сервери заздалегідь налаштовані.", - "simplex-unique-overlay-card-4-p-2": "Платформа SimpleX використовує відкритий протокол та надає SDK для створення чат-ботів, що дозволяє впроваджувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми дійсно чекаємо, які сервіси SimpleX ви зможете побудувати.", - "simplex-unique-overlay-card-4-p-3": "Якщо ви розглядаєте можливість розробки для платформи SimpleX, наприклад, чат-бота для користувачів додатку SimpleX або інтеграції бібліотеки SimpleX Chat у свої мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", - "simplex-unique-card-1-p-1": "SimpleX захищає конфіденційність вашого профілю, контактів та метаданих, приховуючи їх від серверів платформи SimpleX та будь-яких спостерігачів.", - "simplex-unique-card-1-p-2": "На відміну від будь-якої іншої існуючої платформи обміну повідомленнями, SimpleX не має ідентифікаторів, призначених користувачам — навіть випадкових чисел.", - "simplex-unique-card-2-p-1": "Оскільки у вас немає ідентифікатора або фіксованої адреси на платформі SimpleX, ніхто не може з вами зв'язатися, якщо ви не поділитесь одноразовою або тимчасовою адресою користувача, як QR-код або посиланням.", + "simplex-unique-overlay-card-4-p-1": "Ви можете використовувати SimpleX зі своїми власними серверами і при цьому спілкуватися з людьми, які використовують сервери, попередньо налаштовані в додатках.", + "simplex-unique-overlay-card-4-p-2": "Мережа SimpleX використовує відкритий протокол і надає SDK для створення чат-ботів, що дозволяє реалізувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми справді чекаємо, щоб побачити, які сервіси SimpleX ви створите.", + "simplex-unique-overlay-card-4-p-3": "Якщо ви плануєте розробляти для мережі SimpleX, наприклад, чат-бота для користувачів додатка SimpleX або інтеграцію бібліотеки SimpleX Chat у ваші мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", + "simplex-unique-card-1-p-1": "SimpleX захищає конфіденційність вашого профілю, контактів та метаданих, приховуючи їх від серверів мережі SimpleX та будь-яких спостерігачів.", + "simplex-unique-card-1-p-2": "На відміну від будь-якої іншої існуючої мережі для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам — не навіть випадкових чисел.", + "simplex-unique-card-2-p-1": "Оскільки у вас немає ідентифікатора або фіксованої адреси в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", "simplex-unique-card-3-p-1": "SimpleX зберігає всі дані користувачів на пристроях клієнтів у переносному зашифрованому форматі бази даних — його можна передавати на інший пристрій.", "join": "Приєднатися", "we-invite-you-to-join-the-conversation": "Ми запрошуємо вас приєднатися до розмови", @@ -174,8 +174,7 @@ "sign-up-to-receive-our-updates": "Підпишіться, щоб отримувати наші оновлення", "enter-your-email-address": "Введіть свою електронну адресу", "get-simplex": "Отримати SimpleX додаток для настільних комп'ютерів", - "why-simplex-is": "Чому SimpleX є", - "unique": "унікальним", + "why-simplex-is-unique": "Чому SimpleX є унікальним", "learn-more": "Дізнатися більше", "more-info": "Додаткова інформація", "hide-info": "Приховати інформацію", @@ -192,7 +191,7 @@ "installing-simplex-chat-to-terminal": "Встановлення SimpleX Chat для терміналу", "use-this-command": "Використовуйте цю команду:", "see-simplex-chat": "Дивіться SimpleX Chat", - "the-instructions--source-code": "інструкції з того, як його завантажити чи скомпілювати з вихідного коду.", + "the-instructions--source-code": "для інструкції з того, як його завантажити чи скомпілювати з вихідного коду.", "if-you-already-installed-simplex-chat-for-the-terminal": "Якщо ви вже встановили SimpleX Chat для терміналу", "if-you-already-installed": "Якщо ви вже встановили", "simplex-chat-for-the-terminal": "SimpleX Chat для терміналу", @@ -228,7 +227,7 @@ "no-decentralized": "Ні - децентралізовано", "comparison-section-list-point-4": "Якщо сервери оператора порушені. Перевірте безпековий код в Signal та деяких інших додатках для зменшення ризику", "comparison-section-list-point-5": "Не захищає конфіденційність метаданих користувачів", - "comparison-section-list-point-6": "Хоча P2P є розподіленими, вони не є федеративними - вони працюють як єдина мережа", + "comparison-section-list-point-6": "Хоча P2P є розподіленими, вони не є федеративними — вони працюють як одна мережа", "guide-dropdown-1": "Швидкий старт", "guide-dropdown-2": "Відправлення повідомлень", "guide-dropdown-3": "Таємні групи", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 836f7057ee..c0b843ca9e 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -24,7 +24,7 @@ "simplex-network-overlay-card-1-li-2": "与大多数 P2P 网络不同,SimpleX 在设计上没有任何类型的全局用户标识符,甚至临时的也没有。SimpleX 仅使用临时的成对标识符,提供更好的匿名性和元数据保护。", "privacy-matters-overlay-card-1-p-1": "许多大型公司会使用您的人际关系来估算您的收入,决定商品的价格,并向您兜售您并不真正需要的产品。", "simplex-unique-overlay-card-3-p-4": "发送和接收的服务器流量之间没有共同的标识符或密文—— 如果有人在观察它,他们也无法轻易确定谁与谁通信,即使 TLS 受到威胁。", - "simplex-unique-card-4-p-1": "SimpleX 网络是完全去中心化的,并且独立于任何加密货币或除互联网以外的任何其他平台。", + "simplex-unique-card-4-p-1": "SimpleX 网络是完全去中心化的,并且独立于任何加密货币或除互联网以外的任何其他网络。", "join": "加入", "get-simplex": "获取 SimpleX desktop app", "hide-info": "隐藏信息", @@ -49,19 +49,19 @@ "simplex-explained": "SimpleX 简述", "simplex-explained-tab-3-text": "3. 服务器能看到什么", "hero-header": "重新定义隐私", - "simplex-explained-tab-1-p-2": "它是如何在单向消息队列与没有用户识别符的情况下工作的?", - "simplex-explained-tab-2-p-2": "服务器只会单向传输消息,而无法掌握用户的会话或连接的全貌。", + "simplex-explained-tab-1-p-2": "它是如何利用单向消息队列并不利用用户识别符工作的?", + "simplex-explained-tab-2-p-2": "服务器只会单向传输消息,而无法掌握用户的对话或连接的全貌。", "simplex-explained-tab-3-p-1": "服务器对每个队列都有单独的匿名凭证,并且不知道这些凭证属于哪些用户。", "donate": "捐赠", "simplex-explained-tab-2-p-1": "对于每个连接,您都会使用两个单独的消息队列,通过不同的服务器发送和接收消息。", "simplex-chat-protocol": "SimpleX 聊天协议", "smp-protocol": "SMP协议", "chat-protocol": "聊天协议", - "copyright-label": "© 2020-2024 SimpleX | 开源项目", + "copyright-label": "© 2020-2025 SimpleX | 开源项目", "terminal-cli": "命令行程式", "simplex-explained-tab-1-p-1": "您可以创建联系人和群组,并进行双向对话,就像是任何其他即时通讯软件一样。", "hero-p-1": "其他应用——如Signal、Matrix、Session、Briar、Jami、Cwtch 等——都需要用户 ID。
而SimpleX 不需要用户ID,连随机生成的也不需要。
这从根本上改善了您的隐私。", - "hero-subheader": "首个不使用用户ID的即时通讯软件", + "hero-subheader": "首个
没有用户ID的即时通讯软件", "hero-overlay-2-textlink": "SimpleX 是如何工作的?", "hero-2-header-desc": "右侧的视频向您展示了如何通过一次性二维码、面对面交流或通过视频交换链接来连接到您的朋友。您同样可以通过共享邀请链接来进行连接。", "hero-overlay-1-title": "SimpleX 是如何工作的?", @@ -104,7 +104,7 @@ "simplex-private-card-10-point-1": "SimpleX 为每个用户的联系人或群组成员均使用临时匿名成对地址和凭据。", "privacy-matters-3-title": "因无辜结社而被起诉", "privacy-matters-2-overlay-1-title": "隐私给您力量", - "hero-overlay-card-1-p-2": "为了传递消息,SimpleX 使用消息队列的临时匿名成对标识符(而非其他平台使用的用户 ID)来区分您的每个连接——没有长期标识符这种东西。", + "hero-overlay-card-1-p-2": "SimpleX 使用消息队列的临时匿名成对标识符(而非其他网络使用的用户 ID)来传递消息,而每个连接的标识符均不尽相同 — 没有长期标识符这种东西。", "hero-overlay-card-1-p-3": "您定义用于接收消息的服务器、您的联系人—— 您用来向他们发送消息的服务器。 每个会话都很可能会使用两个不同的服务器。", "hero-overlay-card-1-p-4": "此设计可防止在应用程序级别泄漏任何用户元数据。 为了进一步改善隐私并保护您的 IP 地址,您可以通过 Tor 连接到消息服务器。", "hero-overlay-card-1-p-5": "只有客户端设备存储用户配置文件、联系人和群组; 消息均使用两层端到端加密发送。", @@ -115,35 +115,35 @@ "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服务的最私密的应用程序,如果您通过同一个人资料与两个不同的联系人交谈,他们也可以证明他们与同一个人有联系。", "hero-overlay-card-2-p-4": "为防止这些攻击,SimpleX 在其设计中不包含任何用户 ID 。 而且,如果您使用隐身模式,您将为每个联系人显示不同的名称,避免他们之间共享任何数据。", "simplex-network-overlay-card-1-li-1": "P2P 网络依赖于 分布式散列表(DHT) 的某些变体来路由消息。 DHT 在设计上必须平衡可达性和延迟。 SimpleX 比 P2P 具有更好的可达性和更低的延迟,因为消息可以通过通讯双方选择的多个服务器并行地冗余传递。若是在 P2P 网络中,消息则需要使用算法选择,并依次通过 O(log N) 个节点。", - "simplex-network-overlay-card-1-li-4": "P2P 实现(如 BitTorrent)可能会被某些互联网提供商阻止。 SimpleX 与传输协议无关——它可以在标准网络协议上工作,例如 WebSockets。", + "simplex-network-overlay-card-1-li-4": "P2P 实现(如 BitTorrent)可能会被某些互联网提供商阻止。 SimpleX 与传输协议无关 — 它可以在标准网络协议上工作,例如 WebSockets。", "simplex-network-overlay-card-1-li-3": "P2P 并未解决中间人攻击(MITM Attack) 问题。大多数现有的 P2P 实现没有使用带外通讯来进行初始密钥的交换,而 SimpleX 使用带外通讯,或者在某些情况下,使用预先存在的安全和可信连接来进行初始密钥交换。", "simplex-network-overlay-card-1-li-5": "所有已知的 P2P 网络都可能受到 Sybil 攻击,因为每个节点都是可发现的,并且网络作为一个整体运行。 已知的缓解措施不是需要一个中心化的组件就是需要昂贵的工作量证明。而 SimpleX 网络没有服务器可发现性,它是碎片化的并且作为多个隔离的子网运行,这样全网络范围的攻击便无从实现。", "simplex-network-overlay-card-1-li-6": "P2P 网络可能受到 DRDoS 攻击 。客户端有能力重新广播和放大流量,从而导致整个网络范围内的服务中断。 SimpleX 客户端仅中继来自已知连接的流量,因此不能被攻击者用来放大整个网络的流量。", "privacy-matters-overlay-card-1-p-2": "在线零售商知道收入较低的人更有可能在紧急情况下购买商品,因此他们可能会收取更高的价格或取消折扣。", "privacy-matters-overlay-card-1-p-3": "一些金融和保险公司使用社交图谱来确定利率和保费。 它通常会让收入较低的人支付更多—— 它被称为“贫困溢价”。", - "privacy-matters-overlay-card-1-p-4": "SimpleX 平台比任何替代方案都能更好地保护您人际关系层面的隐私,防止您的社交图谱被任何公司或组织使用。 即使人们使用 SimpleX Chat 提供的服务器,我们也不知道用户数量或他们的连接数。", + "privacy-matters-overlay-card-1-p-4": "SimpleX 网络比任何替代方案都能更好地保护您人际关系层面的隐私,防止您的社交图谱被任何公司或组织使用。 即使人们使用 SimpleX Chat 应用预配置的服务器,服务器运营方也不知道用户数量或他们的连接数。", "privacy-matters-overlay-card-2-p-1": "不久前,我们观察到几次大选被一家知名咨询公司操纵,该公司使用我们的社交图谱扭曲我们对现实世界的看法并操纵我们的选票。", - "privacy-matters-overlay-card-2-p-2": "为了客观并做出独立的决定,您需要控制您的信息空间。 而这只有当您使用没有能力访问您的社交图谱的,注重隐私的通信平台时,这才有可能。", - "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一个没有设计任何用户标识符的平台,这样能比任何已知的替代方案都更好地保护您的连接图谱。", + "privacy-matters-overlay-card-2-p-2": "为了客观并做出独立的决定,您需要控制您的信息空间。 而这只有当您使用没有能力访问您的社交图谱的,注重隐私的通信网络时,这才有可能。", + "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一个没有设计任何用户标识符的网络,这样能比任何已知的替代方案都更好地保护您的连接图谱。", "privacy-matters-overlay-card-3-p-2": "最令人震惊的故事之一是 Mohamedou Ould Salahi 在他的回忆录中描述并在毛里塔尼亚电影中展示的经历。 他在未经审判的情况下被关进关塔那摩集中营,并在打电话给他在阿富汗的亲戚后在那里遭受了 15 年的折磨,他被怀疑参与了 9/11 袭击,尽管他在过去 10 年住在德国。", "privacy-matters-overlay-card-3-p-1": "每个人都应该关心他们通信的隐私和安全——无害的谈话会让您处于危险之中,即使您没有什么可隐瞒的。", "privacy-matters-overlay-card-3-p-3": "普通人会因为他们在网上分享的内容而被捕,即使是通过他们的“匿名”帐户,即使是在民主国家。", "privacy-matters-overlay-card-3-p-4": "使用端到端加密的即时通讯软件还不够,我们都应该使用保护我们个人网络隐私——即我们与谁有联系的即时通讯软件。", - "simplex-unique-overlay-card-1-p-1": "与其他消息传递平台不同,SimpleX 没有分配给用户的标识符。 它不依赖电话号码、基于域的地址(如电子邮件或 XMPP)、用户名、公钥甚至随机数来识别其用户—— 我们不知道有多少人使用我们的 SimpleX 服务器。", - "simplex-unique-overlay-card-1-p-2": "为了传递消息,SimpleX 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。 使用 SimpleX 就像为每个联系人使用不同的即时电子邮件或电话,并且无需管理它们。", - "simplex-unique-overlay-card-1-p-3": "这种设计保护了您正在与之通信的人的隐私,将其隐藏在 SimpleX 平台服务器和任何观察者之外。 要对服务器隐藏您的 IP 地址,您可以通过 Tor 连接到 SimpleX 服务器。", - "simplex-unique-overlay-card-4-p-1": "您可以将 SimpleX 与您自己的服务器一起使用,并且仍然可以与使用我们提供的预配置服务器的人进行通信。", + "simplex-unique-overlay-card-1-p-1": "与其他消息网络不同,SimpleX 没有分配给用户的标识符。 它不依赖电话号码、基于域的地址(如电子邮件或 XMPP)、用户名、公钥甚至随机数来识别其用户—— SimpleX 服务器运营方不知道有多少人使用其服务器。", + "simplex-unique-overlay-card-1-p-2": "为了传递消息,SimpleX 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。", + "simplex-unique-overlay-card-1-p-3": "这种设计保护了您正在与之通信的人的隐私,将其隐藏在 SimpleX 网络的服务器和任何观察者之外。 要对服务器隐藏您的 IP 地址,您可以通过 Tor 连接到 SimpleX 服务器。", + "simplex-unique-overlay-card-4-p-1": "您可以将 SimpleX 与您自己的服务器一起使用,并且仍然可以与使用应用中预配置服务器的人们进行通信。", "simplex-unique-card-3-p-2": "端到端加密的消息在被收到前会暂时保存在 SimpleX 中继服务器上,传送完成后它们会被永久删除。", - "simplex-unique-card-1-p-1": "SimpleX 保护您的个人资料、联系人和元数据的隐私,将其隐藏在 SimpleX 平台服务器和任何观察者之外。", - "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 平台上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用程序中,请 联系我们 以获取建议和支持。", + "simplex-unique-card-1-p-1": "SimpleX 保护您的个人资料、联系人和元数据的隐私,不让 SimpleX 网络服务器和任何观察者看到它们。", + "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 网络上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用程序中,请 联系我们 以获取建议和支持。", "simplex-unique-card-4-p-2": "您可以搭配自己的服务器来使用 SimpleX 或使用我们提供的服务器 — 并仍然连接到任何用户。", - "simplex-unique-card-2-p-1": "因为您在 SimpleX 平台上没有标识符或固定地址,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", + "simplex-unique-card-2-p-1": "因为您在 SimpleX 网络上没有标识符或固定地址,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", "simplex-unique-card-3-p-1": "SimpleX 以便携式加密数据库格式将所有用户数据存储在客户端设备上—— 它可以转移到另一个设备。", "sign-up-to-receive-our-updates": "注册以接收我们的更新", "we-invite-you-to-join-the-conversation": "我们邀请您加入对话", "enter-your-email-address": "输入您的电子邮箱地址", "join-the-REDDIT-community": "加入 REDDIT 社区", - "why-simplex-is": "为什么 SimpleX 是", + "why-simplex-is-unique": "为什么 SimpleX 是 独特的", "learn-more": "了解更多", "join-us-on-GitHub": "在 GitHub 上加入我们", "more-info": "更多信息", @@ -164,7 +164,7 @@ "github-repository": "GitHub 仓库", "copy-the-command-below-text": "复制下面的命令并在聊天中使用它:", "privacy-matters-section-header": "为什么隐私很重要", - "the-instructions--source-code": "如何从源代码下载或编译它的说明。", + "the-instructions--source-code": "如何下载或从源代码下载进行编译的说明。", "if-you-already-installed-simplex-chat-for-the-terminal": "如果您已经为终端安装了 SimpleX Chat", "if-you-already-installed": "如果您已经安装", "simplex-chat-for-the-terminal": "用于终端的 SimpleX Chat", @@ -197,17 +197,16 @@ "comparison-section-list-point-4": "如果运营商的服务器受到威胁。 验证 Signal 和其他一些应用程序中的安全代码以缓解该问题", "comparison-section-list-point-1": "通常基于电话号码,在某些情况下基于用户名", "comparison-section-list-point-2": "基于 DNS 的地址", - "comparison-section-list-point-6": "P2P 是分布式的,而非联邦式的 - 它们作为单个网络运行", + "comparison-section-list-point-6": "P2P 是分布式的,但并非联邦式的 — 它们作为单个网络运行", "see-here": "参见此处", "comparison-section-list-point-7": "P2P 网络要么拥有中央权威,要么整个网络可能被攻陷", "simplex-private-card-5-point-1": "SimpleX 为每个加密层进行内容填充来对抗长度扩展攻击。", - "simplex-unique-overlay-card-4-p-2": "SimpleX 平台使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—— 我们真的很期待看到您可以依托SimpleX构建哪些服务。", + "simplex-unique-overlay-card-4-p-2": "SimpleX 网络使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—— 我们真的很期待看到您会依托SimpleX构建哪些服务。", "simplex-unique-overlay-card-2-p-2": "即使使用可选的用户地址,当它被用于发送垃圾邮件联系请求,您可以更改或完全删除它而不会丢失任何连接。", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat 使用便携式加密数据库格式仅将所有用户数据存储在客户端设备上,该格式可以导出并传输到任何支持的设备。", "donate-here-to-help-us": "在这里捐款来帮助我们", - "unique": "独特的", - "simplex-unique-card-1-p-2": "与任何其他现有的消息传递平台不同,SimpleX 没有分配给用户的标识符—— 甚至随机数也没有。", - "simplex-unique-overlay-card-2-p-1": "因为您在 SimpleX 平台上没有标识符,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", + "simplex-unique-card-1-p-2": "与任何其他现有的消息传递网络不同,SimpleX 没有分配给用户的标识符—— 甚至随机数也没有。", + "simplex-unique-overlay-card-2-p-1": "因为您在 SimpleX 网络上没有标识符,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", "simplex-unique-overlay-card-3-p-2": "端到端加密的消息在被收到前会暂时保存在 SimpleX 中继服务器上,传送完成后它们会被永久删除。", "simplex-unique-overlay-card-3-p-3": "与联合网络服务器(电子邮件、XMPP 或 Matrix)不同,SimpleX 服务器不存储用户帐户,它们仅中继消息,保护双方的隐私。", "guide-dropdown-2": "发送消息", @@ -224,7 +223,7 @@ "click-to-see": "点击查看", "menu": "菜单", "guide-dropdown-1": "快速指南", - "docs-dropdown-1": "SimpleX 平台", + "docs-dropdown-1": "SimpleX 网络", "docs-dropdown-5": "托管 XFTP 服务器", "newer-version-of-eng-msg": "本页面有较新的英语版本。", "guide-dropdown-4": "聊天档案", diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index 0967ef424b..324360cb19 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -1 +1,206 @@ -{} +{ + "home": "开始", + "developers": "開發人員", + "reference": "參考", + "blog": "部落格", + "features": "特徵", + "why-simplex": "為什麼選擇SimpleX", + "simplex-privacy": "SimpleX 隱私", + "simplex-network": "SimpleX 網路", + "simplex-explained": "SimpleX 解釋", + "simplex-explained-tab-1-text": "1. 用戶體驗", + "simplex-explained-tab-2-text": "2. 它是如何工作的", + "simplex-explained-tab-3-text": "3. 伺服器可以看到什麼", + "terminal-cli": "終端 CLI", + "hero-header": "重新定義隱私", + "hero-subheader": "第一個沒有 User ID 的 Messenger", + "simplex-explained-tab-3-p-2": "用戶可以通過使用 Tor 訪問伺服器來進一步提高元數據隱私,防止按 IP 位址進行序列化。", + "smp-protocol": "SMP 協定", + "simplex-explained-tab-2-p-2": "伺服器僅單向傳遞消息,無法全面瞭解使用者的對話記錄或連接。", + "simplex-explained-tab-2-p-1": "對於每個連接,您可以使用兩個單獨的消息佇列通過不同的伺服器發送和接收消息。", + "chat-protocol": "聊天協定", + "copyright-label": "© 2020-2025 SimpleX |開源專案", + "donate": "捐助", + "simplex-explained-tab-1-p-1": "您可以創建聯繫人和群組,並進行雙向對話,就像在任何其他 Messenger 中一樣。", + "simplex-explained-tab-1-p-2": "它如何在沒有使用者配置檔標識符的情況下使用單向佇列?", + "simplex-explained-tab-3-p-1": "伺服器對每個佇列都有單獨的匿名憑證,並且不知道它們屬於哪些使用者。", + "chat-bot-example": "聊天機器人示例", + "simplex-chat-protocol": "SimpleX Chat 協定", + "terms-and-privacy-policy": "隱私策略", + "hero-overlay-card-3-p-3": "Trail of Bits 在 2024 年 7 月檢閱了 SimpleX 網路通訊協定的加密設計。閱讀更多。", + "hero-overlay-3-textlink": "安全評估", + "hero-overlay-card-3-p-2": "Trail of Bits 在 2022 年 11 月檢閱了 SimpleX 網路加密和網路元件。閱讀更多。", + "hero-overlay-1-textlink": "用戶ID為何有害私隱?", + "hero-overlay-2-textlink": "SimpleX如何工作?", + "simplex-private-card-5-point-2": "它讓不同長度的訊息在伺服器和網路觀察者看來相同。", + "simplex-private-card-10-point-1": "對用戶與群組成員,SimpleX使用臨時、匿名的地址對與憑證對。", + "privacy-matters-1-title": "廣告投放與價格歧視", + "privacy-matters-1-overlay-1-linkText": "私隱為您省錢", + "privacy-matters-2-title": "操縱選舉", + "privacy-matters-2-overlay-1-title": "私隱給您權力", + "privacy-matters-2-overlay-1-linkText": "私隱給您權力", + "privacy-matters-3-overlay-1-title": "私隱保護您的自由", + "privacy-matters-3-overlay-1-linkText": "私隱保護您的自由", + "simplex-unique-4-title": "您擁有SimpleX網路", + "simplex-unique-4-overlay-1-title": "完全去中心化—用戶擁有 SimpleX 網路", + "hero-overlay-card-1-p-4": "此設計可防止在應用程式層級洩漏任何使用者' 元資料。為了進一步改善隱私並保護您的 IP 位址,您可以透過 Tor 連接到訊息伺服器。", + "hero-overlay-card-1-p-5": "只有用戶端會儲存使用者設定檔、聯絡人和群組;訊息傳送採用雙層端對端加密。", + "hero-overlay-card-2-p-2": "然後他們可以將這些資訊與現有的公開社交網路進行關聯,並確定一些真實身份。", + "simplex-unique-card-1-p-1": "SimpleX 保護您的個人資料、聯絡人和元資料的隱私,使其對 SimpleX 網路伺服器和任何觀察者隱藏。", + "scan-qr-code-from-mobile-app": "用手機應用程式掃描二維碼", + "scan-the-qr-code-with-the-simplex-chat-app": "使用SimpleX Chat應用程式掃描二維碼", + "protocol-3-text": "點對點協議", + "hero-p-1": "其他聊天應用,如Signal、Matrix、Session、Briar、Jami、Cwtch等,使用用戶ID;SimpleX不這樣做。它甚至不使用隨機數。這種做法極大地保護用戶私隱。", + "hero-2-header-desc": "此視訊教您以一次性QR碼、親身或以視訊連結的方式與聯繫人連線。您也可以通過分享邀請連結與人連線。", + "feature-2-title": "端到端加密的圖像、視訊、檔案", + "simplex-private-card-9-point-2": "與傳統的訊息代理相比,它可減小攻擊媒介及可見的詮釋資料量。", + "simplex-private-10-title": "臨時匿名標識符對", + "simplex-private-card-1-point-1": "雙棘輪協定—帶有前向保密、入侵恢復特質的不留記錄即時通訊協定。", + "privacy-matters-3-title": "因無辜關聯而被起訴", + "signing-key-fingerprint": "簽名密鑰指紋(SHA-256)", + "hero-overlay-card-1-p-3": "您決定用哪個/哪些伺服器接收訊息。您的聯絡人用此伺服器向您傳送訊息。往往每個對話都使用兩台不同的伺服器。", + "docs-dropdown-2": "訪問Android檔案", + "simplex-unique-card-2-p-1": "由於您在 SimpleX 網路上沒有任何標識符或固定地址,因此除非您分享一次性或臨時地址 (如 二維碼或連結),任何人都無法與您聯絡。", + "see-here": "見此", + "privacy-matters-overlay-card-3-p-4": "僅使用端對端加密的通訊工具是不夠的,我們都應使用能保護個人網路隱私的通訊工具。", + "hero-overlay-card-1-p-1": "很多用戶問: 如果 SimpleX 沒有用戶標識符,它何以知道向何處傳送訊息?", + "simplex-unique-overlay-card-4-p-2": "SimpleX網路使用開源協議並提供開發包以編寫聊天機器人,允許使用者透過SimpleX Chat應用程式與服務互動—我們非常期待看到您的SimpleX服務之作!", + "hero-overlay-card-1-p-2": "為傳送訊息,SimpleX 使用消息佇列中的臨時匿名標識符對,每連線各異,而不像其他網路一樣使用用戶ID— SimpleX 沒有長期標識符。", + "privacy-matters-overlay-card-3-p-2": "最令人震驚的故事之一是 Mohamedou Ould Salahi 的經歷,描述於他的回憶錄中,並在《The Mauritanian movie》中呈現。他在未經審訊的情況下被送進關塔納摩集中營,並在那裏遭受了長達 15 年的酷刑,原因是他被懷疑參與了 9/11 襲擊事件,而此前 10 年他一直住在德國。", + "simplex-private-2-title": "伺服器附加加密層", + "hero-overlay-card-2-p-1": "如使用者擁有持久性身份,即使只是一個隨機數,例如 Session ID,也會有服務提供者或攻擊者可以觀察使用者如何連線以及傳送了多少條訊息的風險。", + "simplex-unique-overlay-card-4-p-3": "如您正考慮針對 SimpleX 網路進行開發,例如針對 SimpleX 使用者的聊天機器人,或將 SimpleX 聊天函式庫整合至您的手機應用程式,請與我們聯絡以獲取支持與建議。", + "simplex-network-overlay-card-1-li-1": "點對點網路依賴 DHT 的某些變體來路由訊息。DHT 設計必須平衡傳送保證和延遲。與點對點相比,SimpleX 具有更好的傳送保證和更低的延遲,因為訊息可以使用收件者選擇的伺服器,經由多個伺服器並行冗餘地傳送。在點對點網路中,訊息依次經由O(log N)個由演算法選擇的節點。", + "simplex-private-card-3-point-3": "連線恢復被停用,以防止會話攻擊。", + "simplex-private-card-2-point-1": "傳送至收件者的伺服器加密附加層,以防止 TLS 遭到攻擊時,接收和傳送的伺服器流量之間的關聯。", + "simplex-private-card-5-point-1": "SimpleX 為每個加密層使用內容填充,以挫敗通過監控訊息長度的攻擊。", + "comparison-section-list-point-4a": "SimpleX中繼不可能威脅端到端加密。由其他方式驗證安全碼以杜絕攻擊之可能", + "docs-dropdown-6": "WebRTC伺服器", + "feature-3-title": "端到端加密的、去中心化的群組—存在只有用戶自己知道", + "feature-7-title": "可攜、加密存儲—將設定檔移至另一設備", + "back-to-top": "回到頂部", + "click-to-see": "點擊查看", + "menu": "功能表", + "on-this-page": "此頁中", + "docs-dropdown-14": "在商業中使用SimpleX", + "glossary": "詞彙表", + "simplex-chat-repo": "SimpleX Chat儲存庫", + "newer-version-of-eng-msg": "本頁面有更新的英文版本。", + "f-droid-page-simplex-chat-repo-section-text": "掃描二維碼或使用此URL以添加SimpleX儲存庫至您的F-Droid客戶端:", + "jobs": "加入團隊", + "please-use-link-in-mobile-app": "請使用手機應用程式中的連結", + "stable-versions-built-by-f-droid-org": "由F-Droid.org編譯的穩定版", + "please-enable-javascript": "啟用JavaScript以查看二維碼。", + "docs-dropdown-8": "SimpleX通訊錄服務", + "hero-2-header": "創建私密連線", + "hero-overlay-1-title": "SimpleX如何工作?", + "hero-overlay-2-title": "用戶ID為何有害私隱?", + "hero-overlay-3-title": "安全評估", + "feature-1-title": "支持Markdown與編輯的、端到端加密的訊息", + "feature-4-title": "端到端加密的語音訊息", + "feature-5-title": "自刪除訊息", + "feature-6-title": "端到端加密的語音、視訊通話", + "simplex-network-overlay-1-title": "與點對點訊息傳輸協定的比較", + "simplex-private-1-title": "雙層端到端加密", + "simplex-private-3-title": "經安全鑑權的TLS 傳送", + "simplex-private-4-title": "可選的經由Tor訪問", + "simplex-private-5-title": "多層內容填充", + "feature-8-title": "隱身模式—SimpleX獨有", + "simplex-private-6-title": "頻帶外密鑰交換", + "simplex-private-7-title": "訊息完整性驗證", + "simplex-private-8-title": "訊息混雜以降低關聯性", + "simplex-private-card-1-point-2": "如TLS安全受威脅,每個隊列中的NaCL cryptobox可防止關聯訊息隊列間的通訊。", + "simplex-private-9-title": "單向訊息隊列", + "simplex-private-card-10-point-2": "SimpleX使不用用戶設定檔傳輸訊息成為可能,與其他軟體相比提供更強的詮釋資料私隱性。", + "privacy-matters-1-overlay-1-title": "私隱為您省錢", + "simplex-unique-1-title": "您擁有完整的私隱權", + "simplex-unique-1-overlay-1-title": "您的身份、個人資料、聯絡人與詮釋資料完全隱密", + "simplex-unique-2-title": "SimpleX保護您免受垃圾訊息、濫用之害", + "simplex-private-card-4-point-1": "為保護您的 IP 位址,您可透過 Tor 或其他傳輸覆蓋網路訪問伺服器。", + "simplex-unique-2-overlay-1-title": "防止垃圾郵件和濫用的最佳保護", + "simplex-unique-3-title": "您掌控您的數據", + "simplex-unique-3-overlay-1-title": "您的數據的擁有權、控制與安全", + "hero-overlay-card-2-p-4": "SimpleX 通過在設計中不使用任何使用者 ID以防止這些攻擊。而且,如果您使用匿名聊天模式,每個聯絡人都會有不同的顯示名稱,避免他們之間有任何共用資料。", + "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服務的最隱私的應用程式,如果您透過相同的設定檔與兩個不同的人交談,他們也可以證明他們連線的是同一個人。", + "hero-overlay-card-1-p-6": "請參閱 SimpleX 白皮書以閱讀更多資訊。", + "hero-overlay-card-3-p-1": "Trail of Bits 是一家領先的安全和技術顧問公司,客戶包括大型科技公司、政府機構和主要的區塊鏈專案。", + "simplex-network-overlay-card-1-p-1": "點對點通訊協定和應用程式有多種問題,使得它們不如 SimpleX 可靠、分析起來更複雜,而且易受幾種類型的攻擊。", + "simplex-network-overlay-card-1-li-2": "SimpleX 的設計與大多數點對點網路不同,沒有任何類型的全局使用者標識符,即使是臨時標識符。SimpleX 只使用臨時的標識符對,提供更好的匿名性和元資料保護。", + "privacy-matters-overlay-card-1-p-1": "許多大公司利用與您有聯繫的人的資訊來估計您的收入,向您銷售您並不真正需要的產品,以及定價。", + "privacy-matters-overlay-card-1-p-2": "線上零售商知道收入較低的人更有可能進行緊急購物,因此他們可能收取較高的價格或取消折扣。", + "enter-your-email-address": "輸入您的電郵地址", + "get-simplex": "下載SimpleX 桌面版應用程式", + "join-us-on-GitHub": "在GitHub上加入我們", + "donate-here-to-help-us": "於此捐助以幫助我們", + "sign-up-to-receive-our-updates": "登記以收取更新", + "join": "加入", + "simplex-unique-card-3-p-1": "SimpleX 以 可攜式加密數據庫格式 儲存用戶端裝置上的所有使用者資料;這些資料可傳輸至其他設備。", + "simplex-unique-card-3-p-2": "端對端加密的訊息暫時保留在 SimpleX 中繼伺服器上,收到訊息後會被永久刪除。", + "simplex-unique-card-4-p-2": "您可以使用您自己的伺服器運行SimpleX,或使用我們提供的伺服器並仍連線到任何使用者。", + "simplex-unique-overlay-card-3-p-4": "傳送與接收的伺服器流量之間沒有共同的標識符或密文—如果有人觀察,即使 TLS 遭到破壞,也無法輕易確定誰與誰通訊。", + "join-the-REDDIT-community": "加入Reddit社群", + "privacy-matters-overlay-card-3-p-1": "人人都應關心自己通訊的隱私和安全;無害的對話可能會讓您陷入危險,即使您沒有什麼要隱瞞的。", + "privacy-matters-overlay-card-3-p-3": "普通人會因為他們在網路上分享的內容而被捕,即使是透過他們的「匿名」帳號,即使是在民主國家。", + "privacy-matters-overlay-card-2-p-1": "不久前我們觀察到大選被一家聲譽良好的顧問公司操縱,該公司利用我們的社交圖來扭曲我們對真實世界的看法,並操縱我們的選票。", + "comparison-section-list-point-2": "基於DNS的位址", + "comparison-section-list-point-1": "通常基於電話號碼,有時用戶名", + "simplex-private-card-6-point-1": "許多通訊網路容易受到伺服器或網路供應商的中間人攻擊。", + "privacy-matters-overlay-card-1-p-4": "SimpleX 網路比任何其他方式都能更好地保護您的連線隱私,完全防止您的社交圖表被任何公司或組織獲得。即使使用 SimpleX Chat 應用程式中預設的伺服器,伺服器操作員也不會知道使用者的數量或他們的連線。", + "privacy-matters-overlay-card-2-p-2": "要做到客觀和獨立決策,您需要控制自己的資訊空間。只有當使用無法存取您的社交圖表的私人通訊網路時,您才可能做到這一點。", + "simplex-unique-overlay-card-1-p-1": "與其他通訊網路不同,SimpleX 不為使用者指定標識符。它不依賴電話號碼、網域地址 (如電子郵件或 XMPP)、用戶名、公鑰或甚至隨機數來識別使用者;SimpleX 伺服器操作員不知道有多少人使用他們的伺服器。", + "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一個在設計上不使用任何使用者標識符的網路,因此比任何已知的其他方案都能更好地保護您的社交圖。", + "simplex-unique-overlay-card-4-p-1": "您可以使用您自己的伺服器運行SimpleX,並仍與使用應用程式中預設伺服器的人進行通訊。", + "simplex-unique-card-1-p-2": "與任何其他現有的訊息網路不同,SimpleX 不指定使用者的標識符—甚至沒有隨機數。", + "why-simplex-is-unique": "SimpleX為何與眾不同", + "learn-more": "查看更多", + "more-info": "更多訊息", + "hide-info": "隱藏訊息", + "contact-hero-header": "您收到了SimpleX聯絡地址", + "simplex-unique-card-4-p-1": "SimpleX 網路是完全去中心化的,獨立於任何加密貨幣或任何其他網路(除Internet)。", + "invitation-hero-header": "您收到了SimpleX一次性連結", + "contact-hero-subheader": "使用手提電話或平板電腦中的SimpleX應用程式掃描二維碼。", + "connect-in-app": "在應用中連結", + "contact-hero-p-2": "尚未下載SimpleX?", + "contact-hero-p-1": "當您檢視此頁面時,此連結中的公鑰和訊息佇列位址不透過網路傳送;它們包含在連結 URL 的雜湊片段中。", + "contact-hero-p-3": "使用以下連結下載應用程式。", + "to-make-a-connection": "建立連結:", + "install-simplex-app": "安裝 SimpleX 應用程式", + "use-this-command": "使用此命令:", + "scan-the-qr-code-with-the-simplex-chat-app-description": "當您檢視此頁面時,此連結中的公鑰和訊息佇列位址不會透過網路傳送 —它們包含在連結 URL 的雜湊片段中。", + "installing-simplex-chat-to-terminal": "安裝SimpleX至終端", + "open-simplex-app": "開啟SimpleX應用程式", + "if-you-already-installed": "如已安裝", + "simplex-chat-for-the-terminal": "用於終端的 SimpleX Chat", + "if-you-already-installed-simplex-chat-for-the-terminal": "如您已經安裝了用於終端的 SimpleX Chat", + "tap-to-close": "輕觸以關閉", + "simplex-network-1-desc": "所有訊息都經由伺服器傳送,既能提供更好的元資料隱私和可靠的異步訊息傳送,又能避免許多", + "simplex-network-section-desc": "SinpleX Chat 結合點對點和互聯網路的優點,提供最佳的隱私性。", + "copy-the-command-below-text": "複製下面的命令並用於聊天:", + "privacy-matters-section-header": "隱私為何 重要", + "privacy-matters-section-label": "確保您的通訊應用程式無法竊取您的資料!", + "simplex-network-3-header": "SimpleX網路", + "simplex-network-1-overlay-linktext": "點對點網路的弊病", + "simplex-network-2-desc": "SimpleX 中繼伺服器不儲存使用者個人資料、聯絡人和傳送的訊息,也不連線彼此,也沒有伺服器目錄。", + "simplex-network-2-header": "與互聯網路不同", + "comparison-section-header": "與其他協議之比較", + "protocol-2-text": "XMPP、Matrix", + "comparison-point-1-text": "需要全局身份", + "simplex-network-3-desc": "伺服器提供單向佇列以連接使用者,但伺服器無法看到網路連線圖 —只有使用者能看到。", + "protocol-1-text": "Signal,大平台", + "comparison-point-2-text": "中間人攻擊之可能", + "comparison-point-3-text": "對DNS的依賴", + "comparison-point-4-text": "單一或集中式網路", + "comparison-section-list-point-3": "公鑰或其他某種全局獨一的標識符", + "comparison-section-list-point-5": "不保護使用者的元數據隱私", + "comparison-section-list-point-7": "點對點網路必須有中央權威,否則整個網路都可能受到攻擊", + "comparison-section-list-point-4": "如果運營商的伺服器受到攻擊,用其他應用程式(如 Signal)驗證 SimpleX 安全碼,以減輕風險", + "guide-dropdown-8": "應用程式設定", + "guide-dropdown-6": "音視訊通話", + "docs-dropdown-1": "SimpleX 網路", + "guide": "指南", + "docs-dropdown-9": "下載", + "docs-dropdown-11": "常見問題", + "docs-dropdown-12": "安全性", + "docs-dropdown-7": "翻譯SimpleX", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和F-Droid.org 儲存庫使用不同的金鑰為安裝包簽名。若要切換,請匯出聊天資料庫並重新安裝應用程式。" +} 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" } ] } diff --git a/website/src/_data/languages.json b/website/src/_data/languages.json index 0ac05063cc..340cc59da9 100644 --- a/website/src/_data/languages.json +++ b/website/src/_data/languages.json @@ -33,12 +33,31 @@ "flag": "/img/flags/es.svg", "enabled": true }, + { + "label": "fi", + "name": "Suomi", + "flag": "/img/flags/fi.svg", + "enabled": true + }, { "label": "fr", "name": "Français", "flag": "/img/flags/fr.svg", "enabled": true }, + { + "label": "he", + "name": "עִברִית", + "flag": "/img/flags/il.svg", + "enabled": true, + "rtl": true + }, + { + "label": "hu", + "name": "Magyar", + "flag": "/img/flags/hu.svg", + "enabled": true + }, { "label": "it", "name": "Italiano", @@ -89,9 +108,9 @@ }, { "label": "ru", - "name": "Russian", + "name": "Русский", "flag": "/img/flags/ru.svg", - "enabled": false + "enabled": true } - ] + ] } \ No newline at end of file diff --git a/website/src/_includes/blog_previews/20250308.html b/website/src/_includes/blog_previews/20250308.html new file mode 100644 index 0000000000..ab55ecdbca --- /dev/null +++ b/website/src/_includes/blog_previews/20250308.html @@ -0,0 +1,12 @@ +

v6.3 is released:

+ +
    +
  • Preventing spam and abuse in public groups.
  • +
  • Group improvements: mention other members and improved performance.
  • +
  • Better chat navigation: organize chats into lists and jump to found and forwarded messages.
  • +
  • Privacy and security improvements: chat retention period and private media file names.
  • +
+ +

Also, we added Catalan interface language, thanks to our users and Weblate.

+ +

The last but not the least - server builds are now reproducible!

diff --git a/website/src/_includes/sections/simplex_unique.html b/website/src/_includes/sections/simplex_unique.html index 6759e3a5cb..cdccb3dd5a 100644 --- a/website/src/_includes/sections/simplex_unique.html +++ b/website/src/_includes/sections/simplex_unique.html @@ -1,6 +1,6 @@
-

{{ "why-simplex-is" | i18n({}, lang ) | safe }} {{ "unique" | i18n({}, lang ) | safe }}

+

{{ "why-simplex-is-unique" | i18n({}, lang ) | safe }}

diff --git a/website/src/finneyforum.html b/website/src/finneyforum.html deleted file mode 100644 index 06229e4b5d..0000000000 --- a/website/src/finneyforum.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat - Finney Forum group" -description: "Join the group of attendees of Finney Forum 2024" -groupLink: "https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FTlom_0qzRaEWo_4cweE_hzj6KBmqXC8R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAZzyx3sm1tpGsYjXAOR2LxXD0ty1hlAR7Hg0fbCxEoig%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22IfdftVGf9odVOQImmz1I9A%3D%3D%22%7D" -groupLinkText: Open Finney Forum group link -templateEngineOverride: njk ---- diff --git a/website/src/img/flags/fi.svg b/website/src/img/flags/fi.svg new file mode 100644 index 0000000000..470be2d07c --- /dev/null +++ b/website/src/img/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website/src/img/flags/hu.svg b/website/src/img/flags/hu.svg new file mode 100644 index 0000000000..baddf7f5ea --- /dev/null +++ b/website/src/img/flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/src/img/flags/il.svg b/website/src/img/flags/il.svg new file mode 100644 index 0000000000..f43be7e8ed --- /dev/null +++ b/website/src/img/flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/website/src/livestream.html b/website/src/livestream.html new file mode 100644 index 0000000000..ee7a96ab30 --- /dev/null +++ b/website/src/livestream.html @@ -0,0 +1,8 @@ +--- +layout: layouts/group_link.html +title: "SimpleX Chat: Power to the People" +description: "Join the group for livestream Q&A" +groupLink: "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FSkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w%3D%40smp9.simplex.im%2FoVQ-kg2rjMRituleO6t26DhQDPW6OjLL%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEATIRrsU4GwjpF6SeMWa6Li20Rkibgu4ozZMADZfdAZzE%253D%26srv%3Djssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion" +groupLinkText: Open Livestream Q&A group link +templateEngineOverride: njk +--- \ No newline at end of file diff --git a/website/src/monerokon.html b/website/src/monerokon.html deleted file mode 100644 index 060c6883bb..0000000000 --- a/website/src/monerokon.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat - MoneroKon group" -description: "Join the group of attendees of Monero Konferenco 3 - Praha 2023" -groupLink: "https://simplex.chat/contact/#/?v=1-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FIE3ZKT3daRLKdQg1nSXK4U1cUK4A81XQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAN2vLBKKQiTG58nokhiBIpqvLTyfeyey6UbaFGy4cYH8%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%227LTn4BEWw4bD9Gs8snVEJA%3D%3D%22%7D" -groupLinkText: Open MoneroKon group link -templateEngineOverride: njk ---- \ No newline at end of file diff --git a/website/src/rightscon.html b/website/src/rightscon.html deleted file mode 100644 index 8cf9c9b484..0000000000 --- a/website/src/rightscon.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat - RightsCon group" -description: "Join the group of attendees of RightsCon 2023" -groupLink: "https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FyHKMxr06RLUiKent0IREl1rwUtsc1MKs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAwxplfiUKydkqy7Rbl-YQCWUSnrV_ADSd5fWvH17BvEs%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22B3dC9QiKk4AEpGWaLUuPxw%3D%3D%22%7D" -groupLinkText: Open RightsCon group link -templateEngineOverride: njk ---- diff --git a/website/web.sh b/website/web.sh index 4feba4be50..897c87743e 100755 --- a/website/web.sh +++ b/website/web.sh @@ -5,6 +5,7 @@ set -e cp -R docs website/src rm -rf website/src/docs/rfcs rm website/src/docs/lang/*/README.md +rm -rf website/src/docs/dependencies cp -R blog website/src cp -R images website/src rm website/src/blog/README.md